namespace DodoSSH.Client.App.Platform;
///
/// Compares two RPM version strings the way rpm itself does.
///
///
///
/// Why this exists rather than a SemVer comparison. AndroidUpdateChannel settles the same
/// question and its reasoning carries over exactly: the comparison has to be the one the installer will
/// make, because anything else can offer an update the platform then refuses. On the phone that number is
/// Android's versionCode; here it is the RPM version, and the authority that reads it is
/// rpm — so this is rpmvercmp and not an approximation of it.
///
///
/// It matters in a specific direction. The nightly channel is rolling and CI replaces it on every push, so
/// the published version normally only rises — but a revert, a force-push or a re-run of an older commit
/// can lower it, and a client comparing "is this different from mine" would then offer a downgrade that
/// PackageKit declines. The user sees an update that fails, every six hours, forever.
///
///
/// The algorithm is rpm's, including the parts that look like bugs. Segments of digits and segments
/// of letters are compared separately and never against each other; everything that is neither is a
/// separator, so 1.0_1 and 1.0.1 are equal. Leading zeros are stripped, so 1.01 and
/// 1.1 are equal too. ~ sorts before anything including the end of the string, which is what
/// makes 0.0.1~alpha.0.143 older than 0.0.1 and is the whole reason
/// scripts/ci-linux-rpm.sh translates a SemVer prerelease to one. ^ sorts after everything
/// except the end of the string.
///
///
/// Verified against rpm's own answers rather than against a reading of the source: the cases in
/// RpmVersionTests were generated by running rpm.vercmp in a container, so a disagreement is
/// a disagreement with the program that will actually decide.
///
///
internal static class RpmVersion
{
///
/// Orders two RPM version strings.
///
/// The left version.
/// The right version.
/// -1, 0 or 1, as rpmvercmp would answer.
///
/// Null and empty are accepted and treated as equal to each other and older than anything else, which
/// rpm's own Lua binding refuses outright. The refusal is right for a packaging tool and wrong
/// here: this compares a version read off a feed, and a feed that answered with an empty string should
/// leave the client on the build it has rather than throwing on a background timer.
///
internal static int Compare(string? left, string? right)
{
var one = left ?? string.Empty;
var two = right ?? string.Empty;
if (string.Equals(one, two, StringComparison.Ordinal))
{
return 0;
}
int i = 0, j = 0;
while (i < one.Length || j < two.Length)
{
// Separators. Anything that is not alphanumeric, ~ or ^ divides segments and is skipped on
// both sides independently — which is why 1.0_1 and 1.0.1 compare equal.
Skip(one, ref i);
Skip(two, ref j);
if (CompareMarkers(one, i, two, j) is { } marker)
{
if (marker != 0)
{
return marker;
}
// Both sides carried the same marker, so step over it and keep going.
i++;
j++;
continue;
}
if (i >= one.Length || j >= two.Length)
{
break;
}
if (CompareSegments(one, ref i, two, ref j) is { } ordering)
{
return ordering;
}
}
// Both ran out together, so every segment matched and the only differences were separators.
if (i >= one.Length && j >= two.Length)
{
return 0;
}
// Whichever still has characters left has more version, and more version is newer.
return i < one.Length ? 1 : -1;
}
/// Advances past anything that divides segments rather than taking part in them.
private static void Skip(string value, ref int at)
{
while (at < value.Length && !IsVersionCharacter(value[at]))
{
at++;
}
}
///
/// Orders the two sides when either is at a ~ or a ^.
///
///
/// Null when neither side is at a marker, so the caller should compare segments; 0 when both are at
/// the same one, so the caller should step over it; otherwise the ordering.
///
///
/// ~ sorts before everything including the end of the string, which is what carries the whole
/// prerelease ordering. ^ sorts after everything except the end of the string, so
/// 1.0^post is newer than 1.0 and 1.2.3 is older than 1.2.3^ — the end-of-string checks coming first
/// is the difference between the two halves of that sentence.
///
private static int? CompareMarkers(string one, int i, string two, int j)
{
var oneTilde = i < one.Length && one[i] == '~';
var twoTilde = j < two.Length && two[j] == '~';
if (oneTilde || twoTilde)
{
return oneTilde && twoTilde ? 0 : oneTilde ? -1 : 1;
}
var oneCaret = i < one.Length && one[i] == '^';
var twoCaret = j < two.Length && two[j] == '^';
if (!oneCaret && !twoCaret)
{
return null;
}
if (i >= one.Length)
{
return -1;
}
if (j >= two.Length)
{
return 1;
}
return oneCaret && twoCaret ? 0 : oneCaret ? -1 : 1;
}
///
/// Takes one segment from each side and orders them, advancing past both.
///
/// The ordering, or null if the two segments were equal and the walk should continue.
private static int? CompareSegments(string one, ref int i, string two, ref int j)
{
var numeric = char.IsAsciiDigit(one[i]);
var start1 = i;
var start2 = j;
Take(one, ref i, numeric);
Take(two, ref j, numeric);
var segment1 = one.AsSpan(start1, i - start1);
var segment2 = two.AsSpan(start2, j - start2);
// The two sides are at segments of different kinds — digits against letters. rpm calls the
// numeric one newer, always, on the reasoning that a version that has started counting is further
// along than one still spelling. segment1 is never empty: its kind was chosen from its own first
// character.
if (segment2.IsEmpty)
{
return numeric ? 1 : -1;
}
if (numeric)
{
segment1 = segment1.TrimStart('0');
segment2 = segment2.TrimStart('0');
// Longer, once the zeros are gone, is larger. Compared before the characters are, because a
// string comparison would call "9" larger than "10".
if (segment1.Length != segment2.Length)
{
return segment1.Length > segment2.Length ? 1 : -1;
}
}
var ordering = segment1.CompareTo(segment2, StringComparison.Ordinal);
return ordering == 0 ? null : ordering > 0 ? 1 : -1;
}
/// Advances over one run of digits, or one run of letters.
private static void Take(string value, ref int at, bool numeric)
{
while (at < value.Length
&& (numeric ? char.IsAsciiDigit(value[at]) : char.IsAsciiLetter(value[at])))
{
at++;
}
}
/// Whether this character takes part in the comparison rather than dividing it.
private static bool IsVersionCharacter(char value) =>
char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^';
}