using System.Diagnostics; using System.Globalization; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using DodoSSH.Client.Session; namespace DodoSSH.Client.App.Platform; /// What a channel's release says it is publishing. /// /// /// Written by scripts/ci-linux-rpm.sh and read here, and it is a small file this repository /// controls rather than the forge's own release JSON for the reason the android manifest gives: this is /// fetched on a six-hourly timer, and a document whose shape changes when the forge is upgraded is not a /// contract. /// /// /// The comparison is and never . That is the string /// rpm will order the install against, so comparing anything else would let this offer an update /// PackageKit then refuses — the same argument AndroidChannelManifest makes for versionCode. /// The two differ exactly where it matters: 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are one /// build, and only the second sorts below 0.0.1. /// /// /// The version-release string rpm knows the package by. /// What that build calls itself, for a human. /// The asset on the same release that holds it. /// The package's digest, lower-case hex. internal sealed record LinuxChannelManifest( [property: JsonPropertyName("rpmVersion")] string RpmVersion, [property: JsonPropertyName("version")] string Version, [property: JsonPropertyName("rpm")] string Rpm, [property: JsonPropertyName("sha256")] string Sha256); /// One release as the forge describes it. Only the assets are read. internal sealed record LinuxForgeRelease( [property: JsonPropertyName("assets")] IReadOnlyList? Assets); /// One file attached to a release. internal sealed record LinuxForgeAsset( [property: JsonPropertyName("name")] string? Name, [property: JsonPropertyName("browser_download_url")] string? DownloadUrl); [JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] [JsonSerializable(typeof(LinuxChannelManifest))] [JsonSerializable(typeof(LinuxForgeRelease))] internal sealed partial class LinuxForgeJsonContext : JsonSerializerContext; /// /// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it. /// /// /// /// Nothing here replaces anything. That is the difference from /// and it is the decision ADR 0013 rule 11 records rather than an /// unfinished half: on this platform the package manager is already an updater the user trusts, audits and /// controls, so this class does the two things the package manager cannot — notice that a build exists and /// fetch it — and then hands the file to PackageKit, which raises polkit and asks. The shape is the /// phone's, not the desktop's: is false, and the answer may be no. /// /// /// The feed is a constant and the deployment is never asked. ADR 0011 rule 2, carried unchanged /// from both other channels: an operator who could answer the update check could pin a chosen user to a /// build with a known hole, without holding any key at all. /// /// /// The digest is checked and is not a signature. Velopack verifies what it downloads and Android /// refuses a package whose signature does not match the installed one; an RPM fetched over HTTPS and /// handed to PackageKit has neither. So the manifest names a digest and this refuses anything else — /// which catches a truncated download, a caching proxy and a corrupted mirror, and catches nothing at all /// about who built the file, because the same job wrote both. Provenance here is the constant address; a /// signed repository is what would improve on it, and ADR 0013 rule 11 says why there is not one yet. /// /// [SupportedOSPlatform("linux")] internal sealed class LinuxUpdateChannel : IUpdateChannel { /// The project's own forge, and the one address in this file. /// /// The owner half of the path matters for the reason AndroidUpdateChannel records: the /// repository moved between organisations and Gitea leaves a 301 behind, so a client naming the old /// one works until somebody deletes the redirect. /// private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH"; private readonly string channel; private readonly string packageName; private readonly string dataDirectory; private readonly HttpClient http; private readonly Func installedVersion; private readonly Action handToInstaller; private (string Version, string RpmVersion, string Url, string Sha256)? found; private string? fetched; /// Creates a channel for the running build. internal LinuxUpdateChannel(string channel) : this( channel, ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory, new HttpClient(), InstalledRpmVersion, HandToPackageKit) { } /// Creates a channel with its outside world supplied, which is how the tests reach it. internal LinuxUpdateChannel( string channel, string dataDirectory, HttpClient http, Func installedVersion, Action handToInstaller) { ArgumentException.ThrowIfNullOrEmpty(channel); ArgumentException.ThrowIfNullOrEmpty(dataDirectory); ArgumentNullException.ThrowIfNull(http); ArgumentNullException.ThrowIfNull(installedVersion); ArgumentNullException.ThrowIfNull(handToInstaller); this.channel = channel; this.dataDirectory = dataDirectory; this.http = http; this.installedVersion = installedVersion; this.handToInstaller = handToInstaller; packageName = PackageFor(channel); } /// The channel for a copy under the RPM's install root, if rpm says the package is there. internal static IUpdateChannel ForInstall(string channel) => ForInstall(channel, InstalledRpmVersion, () => new LinuxUpdateChannel(channel)); /// /// The channel for a copy under the RPM's install root, with rpm supplied, which is how the tests /// reach it. /// /// /// /// The path says where this copy is and only rpm says who put it there. /// UpdateChannels.LinuxChannel has already established the first, and on an RPM distribution /// that is the whole story. It is not on any other: a locally built pacman or dpkg package that mirrors /// the spec puts the same tree at the same path, and this channel then offered every nightly as newer /// than a version nobody could read, fetched a package that machine has no way to install, and did it /// again every six hours. /// /// /// Asked once, at composition, for the reason the rest of this decision is: who installed this copy /// does not change while it runs. It costs one rpm launch at start on a machine whose path /// already matched, and nothing at all anywhere else. /// /// /// The build's channel, which names the package. /// What rpm says is installed under a package name, or null. /// The channel to use when rpm does own this copy. internal static IUpdateChannel ForInstall( string channel, Func installedVersion, Func whenOwned) { ArgumentException.ThrowIfNullOrEmpty(channel); ArgumentNullException.ThrowIfNull(installedVersion); ArgumentNullException.ThrowIfNull(whenOwned); var package = PackageFor(channel); return installedVersion(package) is null ? new UnavailableUpdateChannel( $"This copy of DodoSSH was installed outside the {package} RPM — rpm has no record of that " + "package here — and an RPM is the only kind of update it can fetch. Update it with the " + "package manager that installed it.") : whenOwned(); } /// /// /// True unconditionally, because whether this copy is one that can be updated was already decided by /// and — a build run from /// a checkout, or installed by something other than rpm, never reaches this class at all. The same /// split Velopack's side uses, where IsInstalled is checked once at composition rather than at /// every call site. /// public bool IsSupported => true; /// public string CurrentVersion => ClientVersion.Current; /// /// /// False, and for the phone's reason rather than the desktop's. Applying hands a file to PackageKit /// and returns; polkit then asks, and the user may say no. A caller that had already torn the vault /// down would answer "not now" with a locked vault and every shell gone. /// public bool ApplyingEndsTheProcess => false; /// /// The forge could not be reached, or refused. /// The release is there and does not carry a usable build. public async Task CheckAsync(CancellationToken cancellationToken) { var release = await ReadAsync( ReleaseUrl(), LinuxForgeJsonContext.Default.LinuxForgeRelease, cancellationToken) .ConfigureAwait(false); var manifestName = $"linux-{channel}.json"; if (Asset(release, manifestName) is not { } manifestAsset) { throw new InvalidOperationException( $"The {channel} release carries no {manifestName}, so there is nothing saying what it " + "publishes."); } var manifest = await ReadAsync( manifestAsset, LinuxForgeJsonContext.Default.LinuxChannelManifest, cancellationToken) .ConfigureAwait(false); if (manifest is null) { throw new InvalidOperationException( $"The {channel} release's {manifestName} could not be read as a manifest."); } // ◆ No answer from rpm is a failure, never a version. ForInstall only built this class because // rpm answered, so silence now is a package removed under a running process or an rpm that // stopped working — and any stand-in number would make every published build look newer and // fetch a package for a machine that cannot install it, which is what this once did. if (installedVersion(packageName) is not { } installed) { throw new InvalidOperationException( $"rpm has no record of {packageName}, so there is no installed version to compare the " + $"{channel} release against."); } // The one place null is returned, and it means what null is documented to mean: this build is // current. Compared the way rpm compares, because rpm is what accepts or refuses the install. if (RpmVersion.Compare(manifest.RpmVersion, installed) <= 0) { return null; } if (Asset(release, manifest.Rpm) is not { } rpm) { throw new InvalidOperationException( $"The {channel} release advertises {manifest.Version} but carries no {manifest.Rpm}."); } found = (manifest.Version, manifest.RpmVersion, rpm, manifest.Sha256); return new AvailableUpdate(manifest.Version); } /// public async Task DownloadAsync( AvailableUpdate update, IProgress progress, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(update); ArgumentNullException.ThrowIfNull(progress); if (found is not { } target || !string.Equals(target.Version, update.Version, StringComparison.Ordinal)) { throw new InvalidOperationException( "That update did not come from this channel's last check. Check again before downloading."); } // Under the profile directory rather than /tmp, because a package this size on a tmpfs is a // machine that runs out of memory downloading an update. One name, overwritten, so a client that // fetched three updates it never installed is not holding three hundred megabytes. var directory = Path.Combine(dataDirectory, "updates"); Directory.CreateDirectory(directory); var path = Path.Combine(directory, $"{packageName}-update.rpm"); await FetchAsync(target.Url, path, progress, cancellationToken).ConfigureAwait(false); // ◆ Verified before it is ever named to the installer, and the file is removed if it does not // match. Leaving a package that failed its digest on disk would put a file somebody could install // by hand next to a filename that says it is an update. var digest = Convert.ToHexStringLower(await Sha256OfAsync(path, cancellationToken).ConfigureAwait(false)); if (!string.Equals(digest, target.Sha256, StringComparison.OrdinalIgnoreCase)) { File.Delete(path); throw new InvalidOperationException( $"The downloaded package is not the one {channel} published: expected {target.Sha256}, " + $"got {digest}."); } fetched = path; } /// Streams one URL to one path, reporting progress as it goes. private async Task FetchAsync( string url, string path, IProgress progress, CancellationToken cancellationToken) { using var response = await http .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); var total = response.Content.Headers.ContentLength ?? 0; var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var destination = File.Create(path); await using (stream.ConfigureAwait(false)) await using (destination.ConfigureAwait(false)) { var buffer = new byte[81920]; long copied = 0; int read; while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) { await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken) .ConfigureAwait(false); copied += read; if (total > 0) { progress.Report((int)(copied * 100 / total)); } } } } /// /// /// /// This installs nothing and returns immediately. It asks PackageKit — the freedesktop service /// every RPM distribution runs — to install a local file, which raises a polkit prompt drawn by the /// desktop rather than by this application. The user may refuse, and refusing is not a failure. /// /// /// The process is not restarted and must not be. Replacing files under a running process is /// ordinary on Linux: the old inodes stay mapped and this build keeps running until somebody closes /// it. That is the right behaviour for an application that deliberately keeps shells open across a /// lock — the same argument ADR 0013 makes for waiting for a restart the user asks for — and it is why /// the name inherited from the interface reads oddly here. There is no equivalent of Android's /// "the platform ends the process", so nothing ends it. /// /// public void ApplyAndRestart(AvailableUpdate update) { ArgumentNullException.ThrowIfNull(update); if (fetched is not { } path || !File.Exists(path)) { throw new InvalidOperationException("There is no downloaded update to install."); } handToInstaller(path); } /// Which release this build's channel reads. /// /// releases/latest skips prereleases, which keeps the nightly — published as one — out of the /// release channel's answer even though both live on the same forge. /// private string ReleaseUrl() => string.Equals(channel, "nightly", StringComparison.Ordinal) ? $"{RepositoryApi}/releases/tags/nightly-desktop" : $"{RepositoryApi}/releases/latest"; /// The package a channel's builds are published as. private static string PackageFor(string channel) => string.Equals(channel, "nightly", StringComparison.Ordinal) ? "dodossh-nightly" : "dodossh"; private static string? Asset(LinuxForgeRelease? release, string name) => release?.Assets?.FirstOrDefault( asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl; private async Task ReadAsync( string url, JsonTypeInfo shape, CancellationToken cancellationToken) { var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { return await JsonSerializer .DeserializeAsync(stream, shape, cancellationToken) .ConfigureAwait(false); } } private static async Task Sha256OfAsync(string path, CancellationToken cancellationToken) { var stream = File.OpenRead(path); await using (stream.ConfigureAwait(false)) { return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); } } /// /// What rpm records for the installed package, which is the number a newer build must beat. /// /// /// /// Read from the package database rather than from the assembly, for the reason /// AndroidUpdateChannel.ReadInstalledVersionCode gives about reading it from the platform: the /// assembly's version is a SemVer string and this comparison has to be the one the installer will /// make. Deriving the rpm version from the assembly here would mean a second copy of the translation /// scripts/ci-linux-rpm.sh performs, and two copies of one rule is a pair that can disagree. /// /// /// A machine that cannot answer gets null — no rpm at all, or one with no such package — and /// null means "rpm does not own this copy", never a version. It used to be read as 0, on the /// argument that the worst case was an install PackageKit declined. That holds only where PackageKit /// installs RPMs: on any other distribution it made every published build look newer, forever, and /// fetched a package that machine could not install. See . /// /// private static string? InstalledRpmVersion(string packageName) { try { using var process = Process.Start(new ProcessStartInfo("rpm") { ArgumentList = { "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName }, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }); if (process is null) { return null; } var answer = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(TimeSpan.FromSeconds(10)); return process.ExitCode == 0 && answer.Length > 0 ? answer : null; } catch (Exception exception) when (exception is not OutOfMemoryException) { return null; } } /// Asks PackageKit to install a local file, and lets polkit do the asking. /// /// /// Over gdbus rather than a D-Bus library, and that is a dependency argument rather than a /// stylistic one. gdbus ships in glib2, which this package already depends on twice over — /// GTK and WebKitGTK are the terminal — so it is present on every machine that can run this at all. /// Adding a managed D-Bus stack to reach two method calls would be a package, a lock file entry and a /// second thing to keep current, for no capability. BrowserLauncher settles the same trade the /// same way one layer down. /// /// /// Two calls, because PackageKit's interface is a factory. CreateTransaction returns an /// object path and the work is asked of that; a transaction is single-use, so this cannot be cached. /// /// /// The flags word is zero, and zero is load-bearing. Bit 0 is ONLY_TRUSTED. This /// package is not signed — ADR 0013 rule 11 says why, and why that is not decision 8's "unsigned for /// now" — so requiring trust would make every update fail at the last step. Clearing it is what routes /// the request to polkit's package-install-untrusted action, which asks for an administrator /// rather than proceeding quietly. The user sees what they are agreeing to. /// /// private static void HandToPackageKit(string path) { var transaction = Gdbus( "--system", "--dest", "org.freedesktop.PackageKit", "--object-path", "/org/freedesktop/PackageKit", "--method", "org.freedesktop.PackageKit.CreateTransaction"); // gdbus prints a GVariant tuple — (objectpath '/17_beadcfae',) — and the path is the only thing // in it. Parsed by taking what is between the quotes rather than with a regular expression over a // format nobody has promised to keep. var opened = transaction.IndexOf('\'', StringComparison.Ordinal); var closed = transaction.LastIndexOf('\''); if (opened < 0 || closed <= opened) { throw new InvalidOperationException( $"PackageKit did not return a transaction to install into: {transaction}"); } var objectPath = transaction[(opened + 1)..closed]; Gdbus( "--system", "--dest", "org.freedesktop.PackageKit", "--object-path", objectPath, "--method", "org.freedesktop.PackageKit.Transaction.InstallFiles", "0", $"['{path}']"); } /// Runs one gdbus call and answers with its output. private static string Gdbus(params string[] arguments) { var start = new ProcessStartInfo("gdbus") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var argument in arguments) { start.ArgumentList.Add(argument); } using var process = Process.Start(start) ?? throw new InvalidOperationException( "gdbus could not be started, so the package could not be handed to PackageKit."); var output = process.StandardOutput.ReadToEnd(); var error = process.StandardError.ReadToEnd(); process.WaitForExit(TimeSpan.FromSeconds(30)); if (process.ExitCode != 0) { // The message is shown to the user by UpdateViewModel, so it says what to do rather than what // failed. A missing PackageKit is the likely cause on a minimal install and is the one thing // they can fix. throw new InvalidOperationException( string.Format( CultureInfo.CurrentCulture, "PackageKit would not take the package. Install it with `sudo dnf install PackageKit`, " + "or install the file yourself: {0}. ({1})", arguments[^1], error.Trim())); } return output; } }