site/lib/version.js (7566B)
1 // Compares upstream PHP versions and Debian/Ubuntu package versions.
2
3 export function parsePhpVersion(text) {
4 const m = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(String(text || '').trim());
5 if (!m) return null;
6 const suffix = m[4];
7 // Pre-releases: "8.6.0RC3", "8.5.12-dev" (a branch snapshot before that release), "8.2.0~rc1-1" (a Debian package of one),
8 // "8.5.12_rc1-r0" and "8.5.0_pre1" (Alpine writes them with an underscore; its "_p1" is a patch level, not a pre-release).
9 // In a Debian version any other "~" suffix of the upstream part ("8.4.27~git20261001-1") sorts before the release as
10 // well: it is a snapshot taken before it. Repack markers ("~dfsg") are not pre-releases.
11 const pre = /^[-.~_]?((?:alpha|beta|RC|dev|pre)\d*)/i.exec(suffix)
12 || /^~(?!(?:dfsg|ds|repack)\d*(?:$|[-+~]))([^-+~]+)/i.exec(suffix);
13 const rest = pre ? suffix.slice(pre[0].length) : suffix;
14 return {
15 major: Number(m[1]),
16 minor: Number(m[2]),
17 patch: Number(m[3]),
18 branch: `${m[1]}.${m[2]}`,
19 upstream: `${m[1]}.${m[2]}.${m[3]}`,
20 pre: pre ? pre[1] : '',
21 // The distribution revision, if one is attached, as in "-1ubuntu2.20" or "-1~deb12u1"
22 revision: rest.startsWith('-') ? rest.slice(1) : '',
23 raw: String(text).trim(),
24 };
25 }
26
27 // Compares major.minor.patch only. For the same numbers, a pre-release such as RC sorts lower.
28 export function comparePhp(a, b) {
29 const pa = typeof a === 'string' ? parsePhpVersion(a) : a;
30 const pb = typeof b === 'string' ? parsePhpVersion(b) : b;
31 if (!pa || !pb) throw new Error(`Cannot parse PHP version: ${a} / ${b}`);
32 for (const k of ['major', 'minor', 'patch']) {
33 if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
34 }
35 if (pa.pre && !pb.pre) return -1;
36 if (!pa.pre && pb.pre) return 1;
37 return 0;
38 }
39
40 // A direct port of dpkg's version comparison (lib/dpkg/version.c).
41 export function compareDebian(a, b) {
42 const pa = splitDebian(a);
43 const pb = splitDebian(b);
44 if (pa.epoch !== pb.epoch) return pa.epoch < pb.epoch ? -1 : 1;
45 const c = verrevcmp(pa.upstream, pb.upstream);
46 if (c !== 0) return c;
47 return verrevcmp(pa.revision, pb.revision);
48 }
49
50 export function splitDebian(version) {
51 let v = String(version || '').trim();
52 let epoch = 0;
53 const colon = v.indexOf(':');
54 if (colon >= 0) {
55 epoch = Number.parseInt(v.slice(0, colon), 10) || 0;
56 v = v.slice(colon + 1);
57 }
58 const hyphen = v.lastIndexOf('-');
59 if (hyphen < 0) return { epoch, upstream: v, revision: '' };
60 return { epoch, upstream: v.slice(0, hyphen), revision: v.slice(hyphen + 1) };
61 }
62
63 const isDigit = (ch) => ch >= '0' && ch <= '9';
64
65 function order(ch) {
66 if (ch === '') return 0;
67 if (isDigit(ch)) return 0;
68 if (/[A-Za-z]/.test(ch)) return ch.charCodeAt(0);
69 if (ch === '~') return -1;
70 return ch.charCodeAt(0) + 256;
71 }
72
73 function verrevcmp(a, b) {
74 let i = 0;
75 let j = 0;
76 const at = (s, k) => (k < s.length ? s[k] : '');
77 while (i < a.length || j < b.length) {
78 let firstDiff = 0;
79 while ((i < a.length && !isDigit(a[i])) || (j < b.length && !isDigit(b[j]))) {
80 const ac = order(at(a, i));
81 const bc = order(at(b, j));
82 if (ac !== bc) return ac < bc ? -1 : 1;
83 i++;
84 j++;
85 }
86 while (at(a, i) === '0') i++;
87 while (at(b, j) === '0') j++;
88 while (isDigit(at(a, i)) && isDigit(at(b, j))) {
89 if (!firstDiff) firstDiff = a.charCodeAt(i) - b.charCodeAt(j);
90 i++;
91 j++;
92 }
93 if (isDigit(at(a, i))) return 1;
94 if (isDigit(at(b, j))) return -1;
95 if (firstDiff) return firstDiff < 0 ? -1 : 1;
96 }
97 return 0;
98 }
99
100 // Repack markers in a Debian upstream version ("5.5.9+dfsg"). PHP_VERSION never carries them.
101 const REPACK = /[+~](?:dfsg|ds|repack)\d*$/i;
102
103 // The upstream part of a package version or a PHP_VERSION string, without epoch and repack
104 // markers: "5.5.9+dfsg-1ubuntu4.29+esm18" -> "5.5.9", "1:8.2.30-1.el9_6" -> "8.2.30".
105 export function debUpstream(version) {
106 return splitDebian(version).upstream.replace(REPACK, '');
107 }
108
109 // A package version or PHP_VERSION string in a form that compares like for like: the epoch
110 // (never in PHP_VERSION; PHP source packages do not use one) and repack markers are dropped.
111 export function debComparable(version) {
112 const { upstream, revision } = splitDebian(version);
113 const up = upstream.replace(REPACK, '');
114 return revision ? `${up}-${revision}` : up;
115 }
116
117 // Builds from Ondřej Surý's repository (deb.sury.org) follow upstream versions directly.
118 export function isSuryBuild(version) {
119 return /deb\.sury\.org|\+0~20\d{6}|\.gbp[0-9a-f]+/.test(String(version || ''));
120 }
121
122 // The build date of a deb.sury.org Debian build ("8.1.34-9+0~20261021.82+debian12~1.gbp4e430a" -> "2026-10-21"), or
123 // null. Its Ubuntu builds ("+ubuntu22.04.1+deb.sury.org+1") carry no date.
124 export function suryBuildDate(version) {
125 const m = /\+0~(20\d{2})(\d{2})(\d{2})/.exec(String(version || ''));
126 return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
127 }
128
129 // rpm's version comparison (rpmvercmp in rpmio/rpmvercmp.c) of one version or release string.
130 function rpmvercmp(a, b) {
131 if (a === b) return 0;
132 const isAlnum = (ch) => /[A-Za-z0-9]/.test(ch);
133 const isAlpha = (ch) => /[A-Za-z]/.test(ch);
134 let i = 0;
135 let j = 0;
136 while (i < a.length || j < b.length) {
137 while (i < a.length && !isAlnum(a[i]) && a[i] !== '~' && a[i] !== '^') i++;
138 while (j < b.length && !isAlnum(b[j]) && b[j] !== '~' && b[j] !== '^') j++;
139 // "~" sorts before everything, even the end of the string.
140 if (a[i] === '~' || b[j] === '~') {
141 if (a[i] !== '~') return 1;
142 if (b[j] !== '~') return -1;
143 i++;
144 j++;
145 continue;
146 }
147 // "^" sorts after the end of the string but before anything else.
148 if (a[i] === '^' || b[j] === '^') {
149 if (i >= a.length) return -1;
150 if (j >= b.length) return 1;
151 if (a[i] !== '^') return 1;
152 if (b[j] !== '^') return -1;
153 i++;
154 j++;
155 continue;
156 }
157 if (i >= a.length || j >= b.length) break;
158 const si = i;
159 const sj = j;
160 const numeric = isDigit(a[i]);
161 if (numeric) {
162 while (i < a.length && isDigit(a[i])) i++;
163 while (j < b.length && isDigit(b[j])) j++;
164 } else {
165 while (i < a.length && isAlpha(a[i])) i++;
166 while (j < b.length && isAlpha(b[j])) j++;
167 }
168 let x = a.slice(si, i);
169 let y = b.slice(sj, j);
170 // A numeric segment is newer than an alphabetic one.
171 if (!y.length) return numeric ? 1 : -1;
172 if (numeric) {
173 x = x.replace(/^0+/, '');
174 y = y.replace(/^0+/, '');
175 if (x.length !== y.length) return x.length < y.length ? -1 : 1;
176 }
177 if (x !== y) return x < y ? -1 : 1;
178 }
179 if (i >= a.length && j >= b.length) return 0;
180 return i >= a.length ? -1 : 1;
181 }
182
183 // Compares two rpm versions "[epoch:]version-release" as rpm does. A missing epoch is 0; when one side has no release,
184 // only the versions are compared.
185 export function compareRpm(a, b) {
186 const split = (s) => {
187 let v = String(s || '').trim();
188 let epoch = 0;
189 const colon = v.indexOf(':');
190 if (colon >= 0) {
191 epoch = Number.parseInt(v.slice(0, colon), 10) || 0;
192 v = v.slice(colon + 1);
193 }
194 const hyphen = v.lastIndexOf('-');
195 return hyphen < 0 ? { epoch, version: v, release: '' } : { epoch, version: v.slice(0, hyphen), release: v.slice(hyphen + 1) };
196 };
197 const pa = split(a);
198 const pb = split(b);
199 if (pa.epoch !== pb.epoch) return pa.epoch < pb.epoch ? -1 : 1;
200 const c = rpmvercmp(pa.version, pb.version);
201 if (c !== 0 || !pa.release || !pb.release) return c;
202 return rpmvercmp(pa.release, pb.release);
203 }