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 == '^'; }