From 3b58d240c68d9a9312363046465d74606221f37e Mon Sep 17 00:00:00 2001 From: potpiemuncher Date: Sun, 26 Jul 2026 20:25:23 -0500 Subject: [PATCH] Phase 2.4: pin, verify and fail closed in the VIIPER setup script The bundled setup script downloaded a kernel-driver installer and ran it with /S on the next line, admitted any usbip-win2 release at or above a floor, checked one of the two driver packages after install, created two autostart entries nobody wanted, and started backends with the upstream self-updater live. This changes all of that. The decisions moved into C#; the script kept the mechanical half. Not for testing convenience: the admission rule is "the manifest decides", the manifest is ViiperDriverManifest, and its own contract says it must not be duplicated into the UI, the broker or the installer. A PowerShell copy of the version table would have been that duplicate, and it would have been the copy deciding whether a kernel driver gets installed. * ViiperInstallerPins - the two exact artefacts setup may fetch: URL, SHA-256, size, required signer, and how the digest was obtained. * ViiperInstallerPolicy - pure decisions: download verdict, usbip install action, post-install verdict, exit codes, autostart plan. Each returns its audit lines with its verdict. * ViiperInstallerPolicyCommand - the read-only -viiperinstallerpolicy verb surface the script consults. * PendingApplicationRestart - issue #12's ordering, enforced. Nothing is executed before its digest and, where the publisher signs, its Authenticode chain and signer have matched a pin. The version floor is gone: the input is now the driver gate's four-state answer, so an installed 0.9.7.8 is recognised, reported as the experimental baseline it is, and left alone rather than downgraded. The package pair Windows actually bound is validated after the driver step through the same implementation as -viiperdriverdiagnostic. The .previous backup survives a successful install. Both autostart mechanisms are gone, and a pre-existing one is reported and removed only when asked, never adopted. Closes #12: the replacement instance is started by the shutdown path after the single-instance handle is closed, and Launch refuses until it has been. The owned backend still stops on exit and the new instance starts a fresh one on demand - exempting the restart would leave an orphan the new instance could never stop. Finishes #8: the script's own verification start takes its argument vector from ViiperBackendSpawn, so it cannot drift from the application, and with both autostart entries gone no path is left that starts an update-nagging backend. 763 tests pass (+67), 0 failed. The script itself is [VM]-gated and was parsed, not run. --- DS4Windows/App.xaml.cs | 42 + DS4Windows/ArgumentParser.cs | 24 + .../DS4Control/PendingApplicationRestart.cs | 201 +++++ .../Viiper/Validation/ViiperInstallerPins.cs | 309 +++++++ .../Validation/ViiperInstallerPolicy.cs | 790 ++++++++++++++++++ .../ViiperInstallerPolicyCommand.cs | 464 ++++++++++ .../DS4Control/Viiper/ViiperSetupManager.cs | 103 ++- .../PendingApplicationRestartTests.cs | 165 ++++ DS4WindowsTests/ViiperInstallerPolicyTests.cs | 684 +++++++++++++++ DS4WindowsTests/ViiperInstallerScriptTests.cs | 215 +++++ docs/dev/PLAN-PROGRESS.md | 183 ++++ extras/install-viiper-backend.ps1 | 552 +++++++----- 12 files changed, 3475 insertions(+), 257 deletions(-) create mode 100644 DS4Windows/DS4Control/PendingApplicationRestart.cs create mode 100644 DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPins.cs create mode 100644 DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicy.cs create mode 100644 DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicyCommand.cs create mode 100644 DS4WindowsTests/PendingApplicationRestartTests.cs create mode 100644 DS4WindowsTests/ViiperInstallerPolicyTests.cs create mode 100644 DS4WindowsTests/ViiperInstallerScriptTests.cs diff --git a/DS4Windows/App.xaml.cs b/DS4Windows/App.xaml.cs index 2fcb171..d754826 100644 --- a/DS4Windows/App.xaml.cs +++ b/DS4Windows/App.xaml.cs @@ -643,6 +643,19 @@ private void CheckOptions(ArgumentParser parser) exitApp = true; Current.Shutdown(diagnosticExitCode); } + else if (parser.ViiperInstallerPolicy) + { + // The bundled setup script asking what it is allowed to do. + // Runs before the ControlService or any window exists, and + // touches nothing except the file it is told to write (plus the + // autostart entries, and only when explicitly asked). + int policyExitCode = + DS4Windows.ViiperInstallerPolicyCommand.Run( + parser.ViiperInstallerPolicyArgs); + runShutdown = false; + exitApp = true; + Current.Shutdown(policyExitCode); + } else if (parser.ReenableDevice) { DS4Windows.DS4Devices.reEnableDevice(parser.DeviceInstanceId); @@ -1070,6 +1083,16 @@ private void CleanShutdown() threadComEvent.Close(); } + // The named single-instance event is gone only now. A queued + // restart may start its replacement from here and no earlier: + // starting it while the event was still open is issue #12, in + // which the replacement saw us as the running instance, exited, + // and left the user with nothing (and, once the backend became + // ours to stop, with no backend either). + DS4Windows.PendingApplicationRestart.Current + .MarkSingleInstanceReleased(); + LaunchPendingRestart(); + if (ipcClassNameMMF != null) ipcClassNameMMF.Dispose(); LogManager.Flush(); @@ -1081,5 +1104,24 @@ private void CleanShutdown() } } } + + private void LaunchPendingRestart() + { + try + { + DS4Windows.PendingApplicationRestart.Current.Launch( + path => Process.Start(new ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + }), + line => logHolder?.Logger?.Info(line)); + } + catch (Exception ex) + { + logHolder?.Logger?.Warn( + "Could not restart after VIIPER setup: " + ex.Message); + } + } } } diff --git a/DS4Windows/ArgumentParser.cs b/DS4Windows/ArgumentParser.cs index 3dab239..2ffca66 100644 --- a/DS4Windows/ArgumentParser.cs +++ b/DS4Windows/ArgumentParser.cs @@ -16,6 +16,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +using System; using System.Collections.Generic; using DS4Windows.DS4Control; @@ -27,6 +28,8 @@ public class ArgumentParser private bool stop; private bool driverinstall; private bool viiperDriverDiagnostic; + private bool viiperInstallerPolicy; + private string[] viiperInstallerPolicyArgs = Array.Empty(); private bool reenableDevice; private string deviceInstanceId; private bool runtask; @@ -41,6 +44,15 @@ public class ArgumentParser public bool Stop { get => stop; } public bool Driverinstall { get => driverinstall; } public bool ViiperDriverDiagnostic { get => viiperDriverDiagnostic; } + public bool ViiperInstallerPolicy { get => viiperInstallerPolicy; } + + /// + /// Everything after -viiperinstallerpolicy, in order: the verb + /// and its options. Passed through verbatim rather than parsed here, + /// because the verb set belongs to the command, not to this switch + /// table. + /// + public string[] ViiperInstallerPolicyArgs { get => viiperInstallerPolicyArgs; } public bool ReenableDevice { get => reenableDevice; } public bool Runtask { get => runtask; } public bool Command { get => command; } @@ -72,6 +84,18 @@ public void Parse(string[] args) viiperDriverDiagnostic = true; break; + // The decision service the bundled VIIPER setup script + // consults. Everything that follows belongs to the verb, so + // the remaining arguments are taken whole and parsing stops. + case "viiperinstallerpolicy": + case "-viiperinstallerpolicy": + viiperInstallerPolicy = true; + viiperInstallerPolicyArgs = new string[args.Length - i - 1]; + Array.Copy(args, i + 1, viiperInstallerPolicyArgs, 0, + viiperInstallerPolicyArgs.Length); + i = args.Length; + break; + case "re-enabledevice": case "-re-enabledevice": reenableDevice = true; diff --git a/DS4Windows/DS4Control/PendingApplicationRestart.cs b/DS4Windows/DS4Control/PendingApplicationRestart.cs new file mode 100644 index 0000000..f817e65 --- /dev/null +++ b/DS4Windows/DS4Control/PendingApplicationRestart.cs @@ -0,0 +1,201 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.IO; + +namespace DS4Windows +{ + /// Why a queued restart did or did not start a replacement. + public enum ViiperRestartLaunchOutcome + { + /// Nothing asked for a restart. + NotRequested, + + /// + /// The single-instance handle has not been released yet. Launching now + /// is the defect this class exists to prevent. + /// + SingleInstanceStillHeld, + + /// A replacement was already started for this request. + AlreadyLaunched, + + /// The replacement was started. + Launched, + + /// Starting the replacement threw. + LaunchFailed, + } + + /// + /// A restart that has been asked for but must not happen yet. + /// + /// The defect this fixes + /// (issue + /// #12). The inherited implementation started the replacement process + /// and only then asked the dispatcher to shut down. The replacement reaches + /// startup in a few hundred milliseconds; the original is still inside an + /// up-to-eight-second controller teardown and still owns the named + /// single-instance event. So the replacement found an instance already + /// running, signalled it, and exited — and the original then finished + /// exiting too. The user was left with nothing running, right after the + /// install whose purpose was to make virtual controllers work. In this tree + /// it is worse: the shutdown also stops the VIIPER backend this process + /// owns, so the end state is no application and no backend. + /// + /// The fix is an ordering, so the ordering is enforced rather than + /// commented. A request only records intent. + /// refuses to start anything until + /// has been called, and that call + /// lives at exactly one place: immediately after the shutdown path closes + /// the single-instance event. A future edit that moves the launch earlier + /// does not reintroduce the race, it fails and says why. + /// + /// What happens to the backend across the restart. Deliberately + /// nothing special: the ordinary stop-on-exit policy from plan task 2.4b + /// runs, the owned backend is stopped with the rest of the shutdown, and + /// the replacement starts a fresh one on demand when a profile needs it. + /// The alternative — exempting an install-driven restart from stop-on-exit — + /// would leave a backend running that the new instance does not own and + /// therefore would never stop, converting a temporary special case into a + /// permanent orphan. A few hundred milliseconds of backend downtime during + /// a restart nobody is playing through is the cheaper side of that + /// trade. + /// + public sealed class PendingApplicationRestart + { + private readonly object gate = new object(); + private string executablePath; + private bool singleInstanceReleased; + private bool launched; + + /// The instance the application's shutdown path drains. + public static PendingApplicationRestart Current { get; } = + new PendingApplicationRestart(); + + /// True once a restart has been asked for and not yet run. + public bool IsRequested + { + get { lock (gate) { return executablePath != null && !launched; } } + } + + /// The executable a launch would start, or null. + public string RequestedExecutable + { + get { lock (gate) { return executablePath; } } + } + + /// + /// Records that the application should be replaced by a fresh instance + /// once this one has finished shutting down. + /// + /// + /// The executable to start. Callers pass Global.exelocation — the + /// executable actually running — rather than a composed + /// <product>.exe, so a renamed or relocated copy still + /// restarts itself. + /// + /// Test seam; defaults to the file system. + /// False when there is nothing runnable to queue. + public bool Request(string exePath, Func fileExists = null) + { + if (string.IsNullOrWhiteSpace(exePath)) + { + return false; + } + + fileExists ??= File.Exists; + if (!fileExists(exePath)) + { + return false; + } + + lock (gate) + { + executablePath = exePath; + launched = false; + return true; + } + } + + /// + /// Called once the named single-instance event has been closed and the + /// shutdown is complete. Until this runs, a replacement would see this + /// process as the running instance and exit immediately. + /// + public void MarkSingleInstanceReleased() + { + lock (gate) { singleInstanceReleased = true; } + } + + /// + /// Starts the queued replacement, if the ordering allows it. + /// + /// + /// Starts the process. Injected so the ordering can be tested without + /// spawning anything. + /// + /// Receives one line describing the outcome. + public ViiperRestartLaunchOutcome Launch(Action start, + Action log = null) + { + string path; + lock (gate) + { + if (executablePath == null) + { + return ViiperRestartLaunchOutcome.NotRequested; + } + + if (launched) + { + log?.Invoke("A replacement " + ProductInfo.ProductName + + " instance was already started; not starting another."); + return ViiperRestartLaunchOutcome.AlreadyLaunched; + } + + if (!singleInstanceReleased) + { + log?.Invoke("Restart of " + ProductInfo.ProductName + + " was skipped: the single-instance handle is still " + + "held, so a replacement would exit immediately."); + return ViiperRestartLaunchOutcome.SingleInstanceStillHeld; + } + + launched = true; + path = executablePath; + } + + try + { + start(path); + log?.Invoke("Restarted " + ProductInfo.ProductName + + " after VIIPER setup. The backend is started again on " + + "demand by the new instance."); + return ViiperRestartLaunchOutcome.Launched; + } + catch (Exception ex) + { + log?.Invoke("Could not restart " + ProductInfo.ProductName + + " after VIIPER setup: " + ex.Message); + return ViiperRestartLaunchOutcome.LaunchFailed; + } + } + } +} diff --git a/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPins.cs b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPins.cs new file mode 100644 index 0000000..603eafa --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPins.cs @@ -0,0 +1,309 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; + +namespace DS4Windows +{ + /// + /// Which bundled component a pin describes. + /// + public enum ViiperInstallerComponent + { + /// The usbip-win2 kernel driver package installer. + UsbipWin2, + + /// The VIIPER userspace backend executable. + ViiperBackend, + } + + /// + /// One exact file the setup script is allowed to fetch and act on. + /// + /// A pin is an identity, not a version floor. Everything the script + /// needs in order to decide whether the bytes in front of it are the bytes + /// this project examined lives here: the URL they come from, their SHA-256, + /// their size, and — where the publisher signs — the Authenticode signer + /// the certificate chain must resolve to. Part 3 rule 2 of the phased plan + /// is the reason there is no "or newer" anywhere in this type. + /// + /// Nothing in a pin is permission to run the file. It says only "this + /// is the artefact whose behaviour was observed"; the tier decision stays + /// with , and for the driver package the + /// installed pair is re-validated afterwards through the same gate the + /// -viiperdriverdiagnostic command uses. + /// + public sealed class ViiperPinnedDownload + { + public ViiperPinnedDownload(ViiperInstallerComponent component, + string releaseLabel, string fileName, string url, string sha256, + long sizeInBytes, bool requireAuthenticode, + string expectedSignerCommonName, string digestProvenance, + string notes = null) + { + if (string.IsNullOrWhiteSpace(releaseLabel)) + throw new ArgumentException("A release label is required.", + nameof(releaseLabel)); + if (string.IsNullOrWhiteSpace(fileName)) + throw new ArgumentException("A file name is required.", + nameof(fileName)); + if (string.IsNullOrWhiteSpace(url)) + throw new ArgumentException("A URL is required.", nameof(url)); + if (string.IsNullOrWhiteSpace(sha256)) + throw new ArgumentException("A SHA-256 digest is required.", + nameof(sha256)); + if (sizeInBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(sizeInBytes)); + if (requireAuthenticode && + string.IsNullOrWhiteSpace(expectedSignerCommonName)) + throw new ArgumentException( + "An expected signer is required when Authenticode is required.", + nameof(expectedSignerCommonName)); + if (string.IsNullOrWhiteSpace(digestProvenance)) + throw new ArgumentException( + "How the digest was obtained has to be recorded.", + nameof(digestProvenance)); + + Component = component; + ReleaseLabel = releaseLabel; + FileName = fileName; + Url = url; + Sha256 = NormalizeDigest(sha256); + SizeInBytes = sizeInBytes; + RequireAuthenticode = requireAuthenticode; + ExpectedSignerCommonName = requireAuthenticode + ? expectedSignerCommonName + : null; + DigestProvenance = digestProvenance; + Notes = notes; + } + + public ViiperInstallerComponent Component { get; } + + /// + /// Upstream release label, e.g. 0.9.7.7 or v0.0.5. For the + /// driver this is the label a carries, + /// which is what ties a download to a tier. + /// + public string ReleaseLabel { get; } + + public string FileName { get; } + + public string Url { get; } + + /// Upper-case hexadecimal, no separators. + public string Sha256 { get; } + + /// + /// Exact published size. Not a security control on its own — a digest + /// match implies it — but it lets a truncated download be named as such + /// instead of being reported as a digest mismatch. + /// + public long SizeInBytes { get; } + + /// + /// Whether a valid Authenticode chain is a precondition for execution. + /// False only where the publisher does not sign at all, and then the + /// reason is spelled out in . + /// + public bool RequireAuthenticode { get; } + + /// + /// Common name the signing certificate must carry, or null when + /// is false. Compared only after + /// Windows has already accepted the chain — it narrows a valid + /// signature to the expected publisher, it never substitutes for chain + /// validation. + /// + public string ExpectedSignerCommonName { get; } + + /// + /// How this project obtained . Recorded in source + /// because a pinned digest whose provenance nobody can restate is a + /// number, not evidence. + /// + public string DigestProvenance { get; } + + /// Anything a reader needs in order not to misread the pin. + public string Notes { get; } + + public bool MatchesDigest(string candidate) => + !string.IsNullOrWhiteSpace(candidate) && + string.Equals(NormalizeDigest(candidate), Sha256, + StringComparison.Ordinal); + + /// + /// Case-insensitive, separator-free upper hex. Callers hand us digests + /// from Get-FileHash, from GitHub's release metadata and from + /// , and those three + /// disagree about case and about colons. + /// + public static string NormalizeDigest(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + string text = value.Trim(); + int separator = text.IndexOf(':'); + if (separator >= 0) + { + text = text.Substring(separator + 1); + } + + return text.Replace("-", string.Empty).Replace(" ", string.Empty) + .Trim().ToUpperInvariant(); + } + } + + /// + /// The exact artefacts extras/install-viiper-backend.ps1 may fetch. + /// + /// Single source of truth on purpose: the script holds no URL and no + /// digest of its own, it asks for these. That is what makes "never fall + /// back to latest" enforceable rather than aspirational — there is no + /// second place a fallback could be written. + /// + public static class ViiperInstallerPins + { + /// + /// usbip-win2 0.9.7.7 x64, the release this project inspected + /// byte-for-byte and validated on a clean Windows 11 checkpoint. + /// + /// Not the newest release, deliberately. 0.9.7.8 exists and is + /// the baseline the maintainer's own machine carries, but it is the + /// release the request-lifetime race was reproduced on, so it is + /// recognised by and never + /// installed by us. + /// + public static ViiperPinnedDownload UsbipWin2 { get; } = + new ViiperPinnedDownload( + component: ViiperInstallerComponent.UsbipWin2, + releaseLabel: "0.9.7.7", + fileName: "USBip-0.9.7.7-x64.exe", + url: "https://github.com/vadimgrn/usbip-win2/releases/download/" + + "v.0.9.7.7/USBip-0.9.7.7-x64.exe", + sha256: + "51620FA5F9F8BE5932BC9D786DEEE557CE06D5407A99CAB490DCFAC71F185FEA", + sizeInBytes: 33226344L, + requireAuthenticode: true, + expectedSignerCommonName: + "Cloudyne Systems (Scheibling Consulting AB)", + digestProvenance: + "SHA-256 of the release asset downloaded for the controlled " + + "Windows 11 validation pass, recomputed from the retained " + + "local copy before pinning.", + notes: + "Inno Setup 6.7.0 payload. Installs UDE DriverVer 21.14.27.907 " + + "and filter DriverVer 21.14.27.661; the installed pair is " + + "re-validated after setup rather than trusted from this pin."); + + /// + /// VIIPER v0.0.5, the backend release whose framed audio/haptics + /// protocol this application negotiates against. + /// + /// Two things a reader has to know. First, upstream does not sign + /// this asset at all — it is an unsigned Go binary published by a + /// release workflow — so the digest is the whole identity and + /// is false + /// rather than a check that would fail on every honest download. + /// Second, the published binary is mis-stamped: it reports + /// v0.0.3-18-g02fffe6 as its own version because the release + /// workflow built it without fetching tags. hbashton/VIIPER#3 (ours) + /// fixes that workflow. Until it lands, nothing may validate this file + /// by the version it claims — only by + /// . + /// + public static ViiperPinnedDownload ViiperBackend { get; } = + new ViiperPinnedDownload( + component: ViiperInstallerComponent.ViiperBackend, + releaseLabel: "v0.0.5", + fileName: "viiper.exe", + url: "https://github.com/hbashton/VIIPER/releases/download/" + + "v0.0.5/viiper.exe", + sha256: + "3AD872D006DF2FC282E381A68B5A5B3C51E4DA3614D250AB3FDA1C272EF745D0", + sizeInBytes: 11255296L, + requireAuthenticode: false, + expectedSignerCommonName: null, + digestProvenance: + "Computed locally from the downloaded hbashton/VIIPER v0.0.5 " + + "asset and cross-checked against the digest GitHub reports " + + "for that same release asset.", + notes: + "Unsigned upstream. The asset mis-reports its own version as " + + "v0.0.3-18-g02fffe6 (hbashton/VIIPER#3 fixes the release " + + "workflow), so the embedded version string is never a " + + "validation input."); + + /// + /// The version string the pinned VIIPER asset reports about itself. + /// Recorded so a diagnostic can say "this is the known mis-stamp" + /// instead of a reader concluding the wrong file was downloaded. + /// + public const string ViiperBackendEmbeddedVersionMisstamp = + "v0.0.3-18-g02fffe6"; + + public static IReadOnlyList All { get; } = + new[] { UsbipWin2, ViiperBackend }; + + public static ViiperPinnedDownload For(ViiperInstallerComponent component) + { + switch (component) + { + case ViiperInstallerComponent.UsbipWin2: + return UsbipWin2; + case ViiperInstallerComponent.ViiperBackend: + return ViiperBackend; + default: + throw new ArgumentOutOfRangeException(nameof(component)); + } + } + + /// + /// Parses the component token the setup script passes on the command + /// line. Returns false for anything unrecognised rather than guessing — + /// a typo must not silently verify the wrong file against the wrong pin. + /// + public static bool TryParseComponent(string token, + out ViiperInstallerComponent component) + { + component = default; + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + switch (token.Trim().ToLowerInvariant()) + { + case "usbip": + case "usbip-win2": + component = ViiperInstallerComponent.UsbipWin2; + return true; + case "viiper": + case "viiper-backend": + component = ViiperInstallerComponent.ViiperBackend; + return true; + default: + return false; + } + } + } +} diff --git a/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicy.cs b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicy.cs new file mode 100644 index 0000000..094d71a --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicy.cs @@ -0,0 +1,790 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace DS4Windows +{ + /// + /// What the setup script should do about the usbip-win2 kernel driver. + /// + public enum ViiperUsbipInstallAction + { + /// + /// Nothing recognisable is installed. Fetch the pinned installer, + /// verify it, and run it. + /// + InstallPinned, + + /// + /// The pinned release is already the installed one. Do nothing. + /// + AlreadyPinned, + + /// + /// A different release the manifest knows is installed. Report what it + /// is and leave it completely alone — replacing a bound kernel package + /// with an older one is not a repair. + /// + LeaveRecognisedReleaseAlone, + + /// + /// Something is installed that cannot be matched to a manifest entry. + /// Touch nothing and do not report success. + /// + RefuseUnrecognisedInstall, + } + + /// + /// A decision plus the log lines that justify it. The lines are the + /// audit trail requirement 5 of plan task 2.4 asks for, produced by the + /// same pure function that makes the call so the two cannot disagree. + /// + public sealed class ViiperInstallerDecision + { + public ViiperInstallerDecision(TAction action, string summary, + IReadOnlyList lines) + { + Action = action; + Summary = summary ?? string.Empty; + Lines = lines ?? Array.Empty(); + } + + public TAction Action { get; } + + /// One sentence naming the decision and its reason. + public string Summary { get; } + + /// + /// Every input the decision was made from, expected value beside + /// observed value, in evaluation order. + /// + public IReadOnlyList Lines { get; } + } + + /// + /// What was actually observed about a downloaded file. Split from the + /// decision so the decision is a pure function of facts and the facts come + /// from one thin, untested I/O helper rather than from the middle of the + /// policy. + /// + public sealed class ViiperDownloadObservation + { + /// The file exists and could be opened. + public bool Exists { get; init; } + + public long SizeInBytes { get; init; } + + /// Hex SHA-256, or null when it could not be computed. + public string Sha256 { get; init; } + + /// + /// Whether Authenticode was evaluated at all. False when the pin does + /// not require it; a pin that requires it and an observation that + /// skipped it is an error, not a pass. + /// + public bool SignatureEvaluated { get; init; } + + /// Windows accepted the certificate chain under normal policy. + public bool SignatureTrusted { get; init; } + + /// Common name read off the verified chain, or null. + public string SignerCommonName { get; init; } + + /// Short non-sensitive reason a signature was rejected. + public string SignatureDiagnostic { get; init; } + + /// Why the file could not be examined at all, or null. + public string ObservationError { get; init; } + } + + /// Verdict on one downloaded file. + public enum ViiperDownloadVerdict + { + /// Digest and (where required) signature both matched the pin. + Approved, + + /// The file was not there, or could not be read. + Unavailable, + + /// The bytes are not the pinned bytes. + DigestMismatch, + + /// Windows would not accept the signature. + SignatureNotTrusted, + + /// A valid signature, but not the expected publisher. + UnexpectedSigner, + } + + /// How a post-install validation attempt turned out. + public enum ViiperPostInstallVerdict + { + /// The gate validated the installed package pair. + Validated, + + /// The gate ran and refused the installed pair. + Refused, + + /// + /// The gate could not run, or its result could not be obtained. Treated + /// exactly like a refusal: an unverifiable state blocks. + /// + CouldNotRun, + } + + /// + /// Every decision extras/install-viiper-backend.ps1 makes, as pure + /// total functions over observed facts. + /// + /// Why this is C# and not PowerShell. The admission rule is + /// "the manifest decides", and the manifest is + /// — a type whose own contract says it + /// must not be duplicated into the UI, the broker or the installer. A + /// PowerShell copy of the version table would be exactly that duplicate, + /// and it would be the copy that decides whether a kernel driver gets + /// installed. Keeping the decisions here means one table, one set of + /// comparisons, and coverage from the test suite that already gates every + /// merge. + /// + /// The script keeps the mechanical half — fetching bytes, running an + /// installer, replacing a file — and consults these functions through + /// for every branch that can end + /// in something being executed. + /// + public static class ViiperInstallerPolicy + { + /// Setup finished and the installed package pair validated. + public const int ScriptExitSuccess = 0; + + /// Setup refused, failed, or could not verify something. + public const int ScriptExitFailed = 1; + + /// + /// Setup completed its file work, but the driver package pair cannot be + /// validated until Windows restarts. Distinct from success because + /// nothing has been proven yet, and distinct from failure because + /// nothing is known to be wrong. + /// + public const int ScriptExitRestartRequired = 3; + + /// + /// Decides what to do about the installed usbip-win2 driver. + /// + /// The primary input is the gate's four-state answer, not a file + /// version. usbip2_ude.sys carries a DriverVer such as + /// 1.45.29.368 that has nothing to do with the 0.9.7.x + /// release label, which is why upstream's -ge 0.9.7.7 floor + /// passes trivially on every install it has ever seen. + /// + /// The gate's readiness state. + /// + /// The manifest release the installed pair matched, or null. + /// + /// Tier of that release, or null. + /// + /// The release label a usbip-win2 uninstall entry reports, if any. Only + /// consulted when the gate found no bound packages, so a + /// half-installed or reboot-pending machine is not mistaken for an + /// empty one. + /// + /// The release this project would install. + /// The releases the product recognises. + public static ViiperInstallerDecision + DecideUsbipInstall(ViiperDriverReadinessState state, + string matchedReleaseLabel, ViiperDriverTier? matchedTier, + string reportedUninstallRelease, ViiperPinnedDownload pin, + ViiperDriverManifest manifest) + { + if (pin == null) throw new ArgumentNullException(nameof(pin)); + manifest ??= ViiperDriverManifest.ObservedBaselines; + + List lines = new List + { + "usbip-win2 pinned release: " + pin.ReleaseLabel + " (" + + pin.FileName + ").", + "usbip-win2 installed state: " + DescribeState(state) + + "; matched release " + Present(matchedReleaseLabel) + + "; tier " + (matchedTier.HasValue + ? matchedTier.Value.ToString() : "(none)") + ".", + }; + + switch (state) + { + case ViiperDriverReadinessState.ValidatedExperimental: + case ViiperDriverReadinessState.Approved: + if (LabelsMatch(matchedReleaseLabel, pin.ReleaseLabel)) + { + return Decide(ViiperUsbipInstallAction.AlreadyPinned, + "usbip-win2 " + pin.ReleaseLabel + + " is already installed and matches the pinned " + + "release exactly; the driver step is skipped.", + lines); + } + + lines.Add( + "The installed release is recognised but is not the " + + "pinned one. Recognising a release is not approving " + + "it, and replacing a bound kernel package with a " + + "different one is not a repair."); + return Decide( + ViiperUsbipInstallAction.LeaveRecognisedReleaseAlone, + "usbip-win2 " + Present(matchedReleaseLabel) + + " is installed. It is a release this build recognises " + + "as an experimental baseline, and it is left exactly " + + "as it is.", + lines); + + case ViiperDriverReadinessState.Missing: + return DecideWhenNothingIsBound(reportedUninstallRelease, + pin, manifest, lines); + + case ViiperDriverReadinessState.DetectedUnvalidated: + lines.Add( + "A usbip-win2 package is present that could not be " + + "matched to any release this build knows, or whose " + + "trust could not be established."); + return Decide( + ViiperUsbipInstallAction.RefuseUnrecognisedInstall, + "Setup will not touch the installed usbip-win2 " + + "packages: they do not match any release this build " + + "recognises. Nothing is installed, removed or " + + "downgraded.", + lines); + + default: + // An enum value from a future build is not a licence to act. + lines.Add("Unrecognised readiness state value '" + + ((int)state).ToString(CultureInfo.InvariantCulture) + + "'; treated as unverifiable."); + return Decide( + ViiperUsbipInstallAction.RefuseUnrecognisedInstall, + "Setup cannot establish what usbip-win2 packages are " + + "installed, so it will not touch them.", + lines); + } + } + + private static ViiperInstallerDecision + DecideWhenNothingIsBound(string reportedUninstallRelease, + ViiperPinnedDownload pin, ViiperDriverManifest manifest, + List lines) + { + string reported = (reportedUninstallRelease ?? string.Empty).Trim(); + lines.Add("usbip-win2 uninstall entry reports: " + Present(reported) + + "."); + + if (reported.Length == 0) + { + return Decide(ViiperUsbipInstallAction.InstallPinned, + "No usbip-win2 driver is installed. Setup will install the " + + "pinned release " + pin.ReleaseLabel + " after verifying it.", + lines); + } + + if (LabelsMatch(reported, pin.ReleaseLabel)) + { + lines.Add( + "The pinned release is registered but its packages are not " + + "bound. Re-running the pinned installer is the repair for " + + "that; it is the same release, so nothing is downgraded."); + return Decide(ViiperUsbipInstallAction.InstallPinned, + "usbip-win2 " + pin.ReleaseLabel + " is registered but not " + + "in service. Setup will reinstall the same pinned release " + + "after verifying it.", + lines); + } + + if (manifest.Releases.Any(release => + LabelsMatch(release.ReleaseLabel, reported))) + { + lines.Add( + "A different recognised release is registered. Setup will " + + "not install over it: that would be a downgrade of a " + + "kernel driver, decided by this script rather than by the " + + "person who installed it."); + return Decide( + ViiperUsbipInstallAction.LeaveRecognisedReleaseAlone, + "usbip-win2 " + reported + " is registered on this machine " + + "but its packages are not currently in service. Setup " + + "leaves it alone; a Windows restart may be needed.", + lines); + } + + lines.Add( + "The registered release is not one this build recognises."); + return Decide(ViiperUsbipInstallAction.RefuseUnrecognisedInstall, + "usbip-win2 " + reported + " is registered on this machine and " + + "is not a release this build recognises. Setup will not " + + "install, replace or remove it.", + lines); + } + + /// + /// Decides whether a downloaded file may be executed or installed. + /// Every branch other than + /// means the file is not touched again. + /// + public static ViiperInstallerDecision + DecideDownloadVerification(ViiperPinnedDownload pin, + ViiperDownloadObservation observation) + { + if (pin == null) throw new ArgumentNullException(nameof(pin)); + + List lines = new List + { + "Verifying " + pin.FileName + " against pinned release " + + pin.ReleaseLabel + ".", + "Source: " + pin.Url, + }; + + if (observation == null || !observation.Exists) + { + lines.Add("File present: expected yes, actual no" + + (observation?.ObservationError is string missingError && + missingError.Length > 0 + ? " (" + missingError + ")" + : string.Empty) + "."); + return Decide(ViiperDownloadVerdict.Unavailable, + "Verification failed: " + pin.FileName + + " is not present, so nothing about it can be verified.", + lines); + } + + lines.Add("Size: expected " + + pin.SizeInBytes.ToString(CultureInfo.InvariantCulture) + + " bytes, actual " + + observation.SizeInBytes.ToString(CultureInfo.InvariantCulture) + + " bytes."); + + string actualDigest = + ViiperPinnedDownload.NormalizeDigest(observation.Sha256); + lines.Add("SHA-256: expected " + pin.Sha256 + ", actual " + + (actualDigest.Length == 0 ? "(not computed)" : actualDigest) + + "."); + + if (actualDigest.Length == 0) + { + if (!string.IsNullOrWhiteSpace(observation.ObservationError)) + { + lines.Add("Digest could not be computed: " + + observation.ObservationError + "."); + } + + return Decide(ViiperDownloadVerdict.Unavailable, + "Verification failed: the SHA-256 of " + pin.FileName + + " could not be computed, so it is treated as unverified.", + lines); + } + + if (!pin.MatchesDigest(actualDigest)) + { + return Decide(ViiperDownloadVerdict.DigestMismatch, + "Verification failed: " + pin.FileName + " does not have " + + "the pinned SHA-256. The file is discarded and nothing is " + + "run from it.", + lines); + } + + if (!pin.RequireAuthenticode) + { + lines.Add("Authenticode: not required for this component (" + + "upstream publishes it unsigned), so the pinned SHA-256 is " + + "the whole identity check."); + return Decide(ViiperDownloadVerdict.Approved, + pin.FileName + " matches the pinned SHA-256 for release " + + pin.ReleaseLabel + ".", + lines); + } + + if (!observation.SignatureEvaluated) + { + lines.Add("Authenticode: expected a verified chain, actual " + + "(not evaluated)."); + return Decide(ViiperDownloadVerdict.Unavailable, + "Verification failed: the Authenticode signature of " + + pin.FileName + " was never evaluated, and an unevaluated " + + "signature is not a valid one.", + lines); + } + + lines.Add("Authenticode chain: expected trusted under normal " + + "Windows policy, actual " + + (observation.SignatureTrusted ? "trusted" : "not trusted") + + (string.IsNullOrWhiteSpace(observation.SignatureDiagnostic) + ? string.Empty + : " (" + observation.SignatureDiagnostic + ")") + "."); + + if (!observation.SignatureTrusted) + { + return Decide(ViiperDownloadVerdict.SignatureNotTrusted, + "Verification failed: Windows does not accept the " + + "Authenticode signature on " + pin.FileName + ".", + lines); + } + + string signer = (observation.SignerCommonName ?? string.Empty).Trim(); + lines.Add("Authenticode signer: expected \"" + + pin.ExpectedSignerCommonName + "\", actual " + + (signer.Length == 0 ? "(not reported)" : "\"" + signer + "\"") + + "."); + + if (!string.Equals(signer, pin.ExpectedSignerCommonName, + StringComparison.OrdinalIgnoreCase)) + { + return Decide(ViiperDownloadVerdict.UnexpectedSigner, + "Verification failed: " + pin.FileName + " carries a valid " + + "signature from a different publisher than the pinned one.", + lines); + } + + return Decide(ViiperDownloadVerdict.Approved, + pin.FileName + " matches the pinned SHA-256 and is signed by " + + "the pinned publisher.", + lines); + } + + /// + /// Maps the exit code of -viiperdriverdiagnostic onto a verdict. + /// Anything other than a clean pass blocks; "could not run" is a + /// failure, not a neutral outcome. + /// + /// + /// False when the diagnostic could not be started at all — for example + /// when the application executable is not next to the script. + /// + public static ViiperInstallerDecision + DecidePostInstallValidation(bool diagnosticRan, int exitCode) + { + List lines = new List(); + + if (!diagnosticRan) + { + lines.Add("Post-install validation: the driver diagnostic could " + + "not be started."); + return Decide(ViiperPostInstallVerdict.CouldNotRun, + "The installed usbip-win2 package pair could not be " + + "validated because the diagnostic could not run. An " + + "unverifiable state is treated as a failure.", + lines); + } + + lines.Add("Post-install validation: driver diagnostic exit code " + + exitCode.ToString(CultureInfo.InvariantCulture) + "."); + + switch (exitCode) + { + case ViiperDriverValidationCommand.ExitCodePassed: + return Decide(ViiperPostInstallVerdict.Validated, + "The installed usbip-win2 package pair matches a " + + "release this build recognises, and its catalogs are " + + "trusted.", + lines); + + case ViiperDriverValidationCommand.ExitCodeFailed: + return Decide(ViiperPostInstallVerdict.Refused, + "The installed usbip-win2 package pair was refused by " + + "the driver gate. Virtual controllers stay blocked " + + "until it validates.", + lines); + + case ViiperDriverValidationCommand.ExitCodeError: + return Decide(ViiperPostInstallVerdict.CouldNotRun, + "The driver diagnostic could not complete, so the " + + "installed package pair is unverified. An unverifiable " + + "state is treated as a failure.", + lines); + + default: + lines.Add("The exit code is not one the diagnostic " + + "documents (0 passed, 1 failed, 2 could not run)."); + return Decide(ViiperPostInstallVerdict.CouldNotRun, + "The driver diagnostic returned an exit code this build " + + "does not recognise, so its result cannot be trusted.", + lines); + } + } + + /// + /// The final exit code of the setup script, from the two facts that + /// decide it. Kept here rather than in the script so the app-side + /// interpretation in is provably the + /// inverse of what the script produces. + /// + public static int ResolveScriptExitCode(ViiperPostInstallVerdict verdict, + bool restartPending) + { + if (verdict == ViiperPostInstallVerdict.Validated) + { + return ScriptExitSuccess; + } + + // A restart that Windows itself asked for explains an unvalidated + // pair without anything being wrong. It is still not success. + return restartPending ? ScriptExitRestartRequired : ScriptExitFailed; + } + + /// + /// What the application should tell the user, and do, when the setup + /// script exits. + /// + public sealed class ViiperInstallerExitReport + { + public bool Succeeded { get; init; } + + /// Restart the application to pick up the new state. + public bool RestartApplication { get; init; } + + /// True when the message should be shown as an error. + public bool IsError { get; init; } + + public string Message { get; init; } + } + + /// + /// Interprets the script's exit code together with the freshly + /// re-probed prerequisite state. + /// + /// The script's process exit code. + /// Whether the backend can now run. + /// Where the script wrote its decisions. + public static ViiperInstallerExitReport DescribeInstallerExit( + int exitCode, bool ready, string logPath) + { + string productName = ProductInfo.ProductName; + string logSuffix = string.IsNullOrWhiteSpace(logPath) + ? string.Empty + : "\n\nEvery decision setup made was written to:\n" + logPath; + + if (exitCode == ScriptExitSuccess && ready) + { + return new ViiperInstallerExitReport + { + Succeeded = true, + RestartApplication = true, + IsError = false, + Message = "VIIPER setup finished. The installed usbip-win2 " + + "package pair was validated. Restarting " + productName + + ".", + }; + } + + if (exitCode == ScriptExitRestartRequired) + { + return new ViiperInstallerExitReport + { + Succeeded = false, + RestartApplication = false, + IsError = false, + Message = "VIIPER was installed, but Windows has to restart " + + "before the driver packages can be validated. Restart " + + "Windows once, then use Refresh." + logSuffix, + }; + } + + if (exitCode == ScriptExitSuccess) + { + // Setup validated the driver, but the backend still is not + // answering. Nothing is known to be broken; nothing is proven + // working either. + return new ViiperInstallerExitReport + { + Succeeded = false, + RestartApplication = false, + IsError = false, + Message = "VIIPER setup reported success, but " + productName + + " cannot see every component as ready yet. Restart " + + "Windows once, then use Refresh." + logSuffix, + }; + } + + return new ViiperInstallerExitReport + { + Succeeded = false, + RestartApplication = false, + IsError = true, + Message = "VIIPER setup did not finish (exit code " + + exitCode.ToString(CultureInfo.InvariantCulture) + ").\n\n" + + "Setup refuses rather than guesses: an unverified download, " + + "an unrecognised driver package, or a backend it could not " + + "stop all end here, and none of them change anything on the " + + "machine." + logSuffix, + }; + } + + /// + /// What to do about VIIPER autostart entries that already exist. + /// + /// Setup never creates them, so anything found was created by + /// something else — a previous install, the upstream script, or + /// viiper.exe install run by hand. Adopting that silently would + /// leave a backend running before the application starts, which is a + /// backend the application will never own, never stop, and — because + /// neither mechanism passes --update-notify none — one whose + /// self-updater is live. + /// + /// What the read-only detector found. + /// + /// Whether the user asked, in this run, for them to be removed. + /// + public static ViiperInstallerDecision + PlanAutostartRemoval(ViiperAutostartStatus status, + bool removalRequested) + { + List lines = new List(); + + if (status == null) + { + lines.Add("VIIPER autostart: could not be inspected."); + return Decide(ViiperAutostartPlanAction.CouldNotInspect, + "Setup could not check whether VIIPER starts at logon.", + lines); + } + + if (!string.IsNullOrEmpty(status.InspectionError)) + { + lines.Add("VIIPER autostart: inspection error - " + + status.InspectionError + "."); + return Decide(ViiperAutostartPlanAction.CouldNotInspect, + "Setup could not check whether VIIPER starts at logon: " + + status.InspectionError + ".", + lines); + } + + if (!status.Any) + { + lines.Add("VIIPER autostart: none found. Setup does not create " + + "any: " + ProductInfo.ProductName + " starts the backend " + + "when a profile needs it and stops it on exit."); + return Decide(ViiperAutostartPlanAction.NothingToDo, + "VIIPER does not start at logon, and setup does not make " + + "it start at logon.", + lines); + } + + foreach (ViiperAutostartEntry entry in status.Entries) + { + lines.Add("VIIPER autostart found: " + entry.Description + + " -> " + entry.Target); + } + + lines.Add("Neither autostart mechanism passes --update-notify none, " + + "so a backend started by one of them runs with its self-updater " + + "enabled (issue #8)."); + + if (removalRequested) + { + return Decide(ViiperAutostartPlanAction.Remove, + "Removing " + status.Entries.Count + + " existing VIIPER autostart entr" + + (status.Entries.Count == 1 ? "y" : "ies") + + " at your request.", + lines); + } + + lines.Add("Left in place: removing somebody else's autostart entry " + + "without being asked is not setup's decision."); + return Decide(ViiperAutostartPlanAction.OfferRemoval, + "VIIPER is set to start at logon by an entry setup did not " + + "create. " + ProductInfo.ProductName + " does not need it, and " + + "Settings has one-click removal.", + lines); + } + + private static ViiperInstallerDecision Decide(T action, + string summary, List lines) + { + lines.Add("Decision: " + summary); + return new ViiperInstallerDecision(action, summary, + lines.ToArray()); + } + + private static bool LabelsMatch(string left, string right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + { + return false; + } + + return string.Equals(NormalizeLabel(left), NormalizeLabel(right), + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Release labels reach us as 0.9.7.7, v.0.9.7.7 and + /// v0.0.5 depending on who wrote them down. Only the leading + /// v/v. is normalised away; the digits are compared + /// literally, because "close enough" is how a floor comparison gets + /// reinvented. + /// + private static string NormalizeLabel(string label) + { + string text = label.Trim(); + if (text.StartsWith("v.", StringComparison.OrdinalIgnoreCase)) + { + return text.Substring(2).Trim(); + } + + if (text.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + return text.Substring(1).Trim(); + } + + return text; + } + + private static string DescribeState(ViiperDriverReadinessState state) + { + switch (state) + { + case ViiperDriverReadinessState.Missing: + return "no packages bound"; + case ViiperDriverReadinessState.DetectedUnvalidated: + return "present but unvalidated"; + case ViiperDriverReadinessState.ValidatedExperimental: + return "matches a recognised experimental baseline"; + case ViiperDriverReadinessState.Approved: + return "matches an approved release"; + default: + return "unknown"; + } + } + + private static string Present(string value) => + string.IsNullOrWhiteSpace(value) ? "(none)" : value; + } + + /// What setup should do about pre-existing VIIPER autostart. + public enum ViiperAutostartPlanAction + { + /// No entry exists, and setup creates none. + NothingToDo, + + /// Entries exist; report them and point at the removal switch. + OfferRemoval, + + /// Entries exist and removal was explicitly requested. + Remove, + + /// The check itself failed; never reported as "none". + CouldNotInspect, + } +} diff --git a/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicyCommand.cs b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicyCommand.cs new file mode 100644 index 0000000..c549703 --- /dev/null +++ b/DS4Windows/DS4Control/Viiper/Validation/ViiperInstallerPolicyCommand.cs @@ -0,0 +1,464 @@ +/* +DS4Windows +Copyright (C) 2026 DS4Windows contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace DS4Windows +{ + /// + /// The -viiperinstallerpolicy switch: the read-only decision service + /// extras/install-viiper-backend.ps1 consults before it does + /// anything irreversible. + /// + /// Contract with the script. Every verb writes a UTF-8 + /// key=value file at --out and returns an exit code. Results + /// go to a file rather than to stdout because this is a WPF (GUI subsystem) + /// process: whether its console output reaches a caller's pipe depends on + /// how the caller launched it, and a verification result that sometimes + /// arrives is not a verification result. Lines beginning log= are the + /// decision audit trail and the script copies them verbatim into + /// install.log. + /// + /// Read-only with respect to the machine, with exactly one + /// exception: autostart --remove, which is reached only when the + /// person running setup asked for it. Nothing here installs, elevates, + /// attaches, starts a backend, or touches a driver. + /// + public static class ViiperInstallerPolicyCommand + { + /// The verb produced a decision. Read it from the out-file. + public const int ExitDecided = 0; + + /// The verb produced a refusal. + public const int ExitRefused = 1; + + /// + /// The verb could not run: a bad argument list, an unwritable out-file, + /// or an unexpected failure. The script treats this exactly like a + /// refusal; it is distinct only so the cause is visible in the log. + /// + public const int ExitCouldNotRun = 2; + + private const string VerbPins = "pins"; + private const string VerbVerifyFile = "verify-file"; + private const string VerbUsbipDecision = "usbip-decision"; + private const string VerbValidateInstalled = "validate-installed"; + private const string VerbAutostart = "autostart"; + + /// + /// Runs one verb. is everything after the + /// -viiperinstallerpolicy switch. + /// + public static int Run(IReadOnlyList args) + { + List output = new List(); + int exitCode; + + try + { + exitCode = Dispatch(args ?? Array.Empty(), output); + } + catch (Exception ex) + { + output.Add("error=" + Sanitize(ex.GetType().Name + ": " + + ex.Message)); + output.Add("log=Installer policy could not run: " + + Sanitize(ex.Message)); + exitCode = ExitCouldNotRun; + } + + output.Insert(0, "exitcode=" + + exitCode.ToString(CultureInfo.InvariantCulture)); + + string outPath = ReadOption(args, "--out"); + if (!TryWrite(outPath, output)) + { + // The script cannot read a decision it never received, and it + // must not proceed on silence. + return ExitCouldNotRun; + } + + return exitCode; + } + + private static int Dispatch(IReadOnlyList args, + List output) + { + string verb = args.Count > 0 ? args[0].Trim().ToLowerInvariant() : null; + switch (verb) + { + case VerbPins: + return EmitPins(output); + case VerbVerifyFile: + return VerifyFile(args, output); + case VerbUsbipDecision: + return DecideUsbip(args, output); + case VerbValidateInstalled: + return ValidateInstalled(output); + case VerbAutostart: + return PlanAutostart(args, output); + default: + output.Add("error=unknown verb"); + output.Add("log=Installer policy was asked for an unknown " + + "action '" + Sanitize(verb ?? string.Empty) + "'."); + return ExitCouldNotRun; + } + } + + /// + /// Emits the pinned identities and the backend argument vector. The + /// script holds no URL, digest or backend flag of its own; it asks for + /// them here so there is exactly one place a pin can be changed and + /// exactly one place a fallback could be introduced. + /// + private static int EmitPins(List output) + { + foreach (ViiperPinnedDownload pin in ViiperInstallerPins.All) + { + string prefix = Key(pin.Component) + "."; + output.Add(prefix + "release=" + pin.ReleaseLabel); + output.Add(prefix + "filename=" + pin.FileName); + output.Add(prefix + "url=" + pin.Url); + output.Add(prefix + "sha256=" + pin.Sha256); + output.Add(prefix + "size=" + + pin.SizeInBytes.ToString(CultureInfo.InvariantCulture)); + output.Add(prefix + "requireauthenticode=" + + (pin.RequireAuthenticode ? "true" : "false")); + output.Add(prefix + "signer=" + + Sanitize(pin.ExpectedSignerCommonName ?? string.Empty)); + output.Add("log=Pinned " + Key(pin.Component) + ": " + + pin.FileName + " (" + pin.ReleaseLabel + "), SHA-256 " + + pin.Sha256 + ", " + (pin.RequireAuthenticode + ? "signed by \"" + pin.ExpectedSignerCommonName + "\"" + : "unsigned upstream") + "."); + } + + // The one argument vector that starts the backend, from the same + // constant the application spawns with (issue #8). The script must + // not spell it out again. + output.Add("viiper.serverargs=" + + string.Join(" ", ViiperBackendSpawn.ServerArguments)); + output.Add("viiper.updatenotifyenv=" + + ViiperBackendSpawn.UpdateNotifyEnvironmentVariable); + output.Add("viiper.updatenotifyvalue=" + + ViiperBackendSpawn.UpdateNotifyDisabled); + output.Add("log=Backend start arguments: " + + string.Join(" ", ViiperBackendSpawn.ServerArguments) + + " (the update notifier is disabled on every path that starts " + + "the backend)."); + return ExitDecided; + } + + private static int VerifyFile(IReadOnlyList args, + List output) + { + string componentToken = ReadOption(args, "--component"); + if (!ViiperInstallerPins.TryParseComponent(componentToken, + out ViiperInstallerComponent component)) + { + output.Add("error=unknown component"); + output.Add("log=Installer policy was asked to verify an unknown " + + "component '" + Sanitize(componentToken ?? string.Empty) + + "'."); + return ExitCouldNotRun; + } + + string path = ReadOption(args, "--path"); + if (string.IsNullOrWhiteSpace(path)) + { + output.Add("error=missing path"); + output.Add("log=Installer policy was asked to verify a file " + + "without being told which one."); + return ExitCouldNotRun; + } + + ViiperPinnedDownload pin = ViiperInstallerPins.For(component); + ViiperDownloadObservation observation = Observe(path, pin); + ViiperInstallerDecision decision = + ViiperInstallerPolicy.DecideDownloadVerification(pin, observation); + + output.Add("verdict=" + decision.Action); + output.Add("summary=" + Sanitize(decision.Summary)); + output.Add("expectedsha256=" + pin.Sha256); + output.Add("actualsha256=" + Sanitize(observation.Sha256 ?? string.Empty)); + output.Add("expectedsigner=" + + Sanitize(pin.ExpectedSignerCommonName ?? string.Empty)); + output.Add("actualsigner=" + + Sanitize(observation.SignerCommonName ?? string.Empty)); + AppendLog(output, decision.Lines); + + return decision.Action == ViiperDownloadVerdict.Approved + ? ExitDecided + : ExitRefused; + } + + /// + /// The only I/O in this file's decision path, kept deliberately thin: + /// read the length, hash the bytes, and — when the pin requires it — + /// ask Windows about the signature. Everything judgemental happens in + /// . + /// + private static ViiperDownloadObservation Observe(string path, + ViiperPinnedDownload pin) + { + FileInfo info; + try + { + info = new FileInfo(path); + if (!info.Exists) + { + return new ViiperDownloadObservation { Exists = false }; + } + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException || ex is ArgumentException || + ex is NotSupportedException) + { + return new ViiperDownloadObservation + { + Exists = false, + ObservationError = ex.Message, + }; + } + + string digest; + try + { + using FileStream stream = File.OpenRead(path); + digest = Convert.ToHexString(SHA256.HashData(stream)); + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException) + { + return new ViiperDownloadObservation + { + Exists = true, + SizeInBytes = info.Length, + ObservationError = ex.Message, + }; + } + + if (!pin.RequireAuthenticode) + { + return new ViiperDownloadObservation + { + Exists = true, + SizeInBytes = info.Length, + Sha256 = digest, + SignatureEvaluated = false, + }; + } + + ViiperSignatureTrust trust = + new WinTrustAuthenticodeVerifier().VerifyFile(path); + return new ViiperDownloadObservation + { + Exists = true, + SizeInBytes = info.Length, + Sha256 = digest, + SignatureEvaluated = true, + SignatureTrusted = trust.Trusted, + SignerCommonName = trust.ObservedSignerCommonName, + SignatureDiagnostic = trust.Diagnostic, + }; + } + + private static int DecideUsbip(IReadOnlyList args, + List output) + { + // The gate, not a file version. Read-only: it enumerates driver + // packages and verifies catalog trust, and does nothing else. + ViiperDriverReadiness readiness = + ViiperSetupManager.RefreshDriverReadiness(); + + ViiperInstallerDecision decision = + ViiperInstallerPolicy.DecideUsbipInstall( + readiness.State, readiness.ReleaseLabel, readiness.Tier, + ReadOption(args, "--uninstall-version"), + ViiperInstallerPins.UsbipWin2, + ViiperDriverManifest.ObservedBaselines); + + output.Add("action=" + decision.Action); + output.Add("summary=" + Sanitize(decision.Summary)); + output.Add("readiness=" + readiness.State); + output.Add("matchedrelease=" + + Sanitize(readiness.ReleaseLabel ?? string.Empty)); + foreach (string reason in readiness.Reasons) + { + output.Add("log=Driver gate reason: " + Sanitize(reason)); + } + + AppendLog(output, decision.Lines); + return decision.Action == + ViiperUsbipInstallAction.RefuseUnrecognisedInstall + ? ExitRefused + : ExitDecided; + } + + /// + /// Validates the package pair Windows actually bound, after the driver + /// step. Runs + /// — the same implementation, and the same 0/1/2 exit code, that the + /// -viiperdriverdiagnostic switch runs — and reports the verdict + /// through the out-file instead of through a console. + /// + /// Going through the out-file rather than launching + /// -viiperdriverdiagnostic as a second process is deliberate. + /// That switch prints to an attached parent console and, when there is + /// none, opens a modal report window; a setup script that is sometimes + /// blocked on a dialog nobody can see is not a verification step. + /// + private static int ValidateInstalled(List output) + { + ViiperDriverDiagnosticRun run = + ViiperDriverValidationCommand.RunDiagnostic(); + + ViiperInstallerDecision decision = + ViiperInstallerPolicy.DecidePostInstallValidation(true, + run.ExitCode); + + output.Add("verdict=" + decision.Action); + output.Add("summary=" + Sanitize(decision.Summary)); + output.Add("diagnosticexit=" + + run.ExitCode.ToString(CultureInfo.InvariantCulture)); + output.Add("reportpath=" + Sanitize(run.DisplayPath ?? string.Empty)); + AppendLog(output, decision.Lines); + if (!string.IsNullOrWhiteSpace(run.DisplayPath)) + { + output.Add("log=Full driver report saved to " + + Sanitize(run.DisplayPath) + "."); + } + + return decision.Action == ViiperPostInstallVerdict.Validated + ? ExitDecided + : ExitRefused; + } + + private static int PlanAutostart(IReadOnlyList args, + List output) + { + bool removalRequested = HasFlag(args, "--remove"); + ViiperAutostartStatus status = ViiperAutostart.Inspect(); + ViiperInstallerDecision decision = + ViiperInstallerPolicy.PlanAutostartRemoval(status, + removalRequested); + + output.Add("action=" + decision.Action); + output.Add("summary=" + Sanitize(decision.Summary)); + output.Add("count=" + status.Entries.Count.ToString( + CultureInfo.InvariantCulture)); + AppendLog(output, decision.Lines); + + if (decision.Action == ViiperAutostartPlanAction.Remove) + { + foreach (string outcome in ViiperAutostart.Remove(status.Entries)) + { + output.Add("log=" + Sanitize(outcome)); + } + } + + return decision.Action == ViiperAutostartPlanAction.CouldNotInspect + ? ExitCouldNotRun + : ExitDecided; + } + + private static void AppendLog(List output, + IEnumerable lines) + { + foreach (string line in lines) + { + output.Add("log=" + Sanitize(line)); + } + } + + private static string Key(ViiperInstallerComponent component) => + component == ViiperInstallerComponent.UsbipWin2 ? "usbip" : "viiper"; + + /// + /// The out-file is line-oriented, so a value may not contain a line + /// break. Nothing emitted here is user-supplied text, but an exception + /// message can be multi-line and would otherwise desynchronise the + /// reader. + /// + private static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value.Replace("\r\n", " ").Replace('\r', ' ') + .Replace('\n', ' ').Trim(); + } + + private static string ReadOption(IReadOnlyList args, string name) + { + if (args == null) + { + return null; + } + + for (int i = 0; i < args.Count - 1; i++) + { + if (string.Equals(args[i], name, + StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + + return null; + } + + private static bool HasFlag(IReadOnlyList args, string name) => + args != null && args.Any(arg => + string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)); + + private static bool TryWrite(string path, IEnumerable lines) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + string directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllLines(path, lines, new UTF8Encoding(false)); + return true; + } + catch (Exception ex) when (ex is IOException || + ex is UnauthorizedAccessException || ex is ArgumentException || + ex is NotSupportedException) + { + return false; + } + } + } +} diff --git a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs index f041c65..a02e0ac 100644 --- a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs +++ b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs @@ -403,82 +403,75 @@ private static void InstallerProcess_Exited(Process process, RefreshDriverReadiness(); ViiperPrerequisiteStatus refreshed = GetStatus( tryStartServer: true); - if (exitCode == 0 && refreshed.Ready) + + ViiperInstallerPolicy.ViiperInstallerExitReport report = + ViiperInstallerPolicy.DescribeInstallerExit(exitCode, + refreshed.Ready, InstallLogPath); + + if (report.Succeeded) { Interlocked.Exchange(ref promptShownThisSession, 0); - AppLogger.LogToGui( - "SUCCESSFUL: VIIPER setup finished successfully. Virtual controllers are ready. Restarting " + - ProductInfo.ProductName + ".", - false, false); - RestartApplication(); + } + + AppLogger.LogToGui((report.Succeeded ? "SUCCESSFUL: " : string.Empty) + + report.Message.Replace("\n", " "), report.IsError, false); + + if (report.RestartApplication && RequestRestart()) + { return; } - string logPath = Path.Combine( - Environment.GetFolderPath( - Environment.SpecialFolder.LocalApplicationData), - "VIIPER", "install.log"); - string message = exitCode == 0 - ? "VIIPER was installed, but Windows is not reporting every component as ready yet. Restart Windows once, then click Refresh." - : $"VIIPER setup could not finish (exit code {exitCode}).\n\n" + - "If a viiper.exe process was still running, it may have blocked the VIIPER registration step. " + - $"Close viiper.exe manually and run Repair again.\n\nReview the setup log for details:\n{logPath}"; - ShowInstallerMessage(owner, message, "VIIPER setup", - exitCode == 0 ? MessageBoxImage.Warning : - MessageBoxImage.Error); + if (!report.Succeeded) + { + ShowInstallerMessage(owner, report.Message, "VIIPER setup", + report.IsError ? MessageBoxImage.Error : + MessageBoxImage.Warning); + } })); } - private static void RestartApplication() + /// Where the setup script records every decision it made. + public static string InstallLogPath => Path.Combine( + Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData), + "VIIPER", "install.log"); + + /// + /// Queues the restart and begins shutting down. The replacement is + /// started by the shutdown path itself, once the single-instance handle + /// is released — see for why + /// starting it here instead is issue #12. + /// + /// False when there was nothing to restart. + private static bool RequestRestart() { // Global.exelocation, not a composed ".exe" under // exedirpath: it is the executable actually running, so it survives // a rename, a portable copy, and the junction/Scoop case that // exelocation already resolves. - string exePath = Global.exelocation; - if (!File.Exists(exePath)) + if (!PendingApplicationRestart.Current.Request(Global.exelocation)) { AppLogger.LogToGui("VIIPER setup succeeded, but " + ProductInfo.ExeBaseName + ".exe was not found for automatic restart.", true, true); - return; + return false; } - ThreadPool.QueueUserWorkItem(_ => + try { - Thread.Sleep(2000); - - try - { - ProcessStartInfo startInfo = new ProcessStartInfo - { - FileName = exePath, - UseShellExecute = true, - }; - Process.Start(startInfo); - } - catch (Exception ex) - { - AppLogger.LogToGui( - $"Could not restart {ProductInfo.ProductName} automatically after VIIPER install: {ex.Message}", - true, true); - return; - } - - try - { - Application.Current?.Dispatcher?.BeginInvoke(new Action(() => - { - Application.Current.Shutdown(); - })); - } - catch (Exception ex) + Application.Current?.Dispatcher?.BeginInvoke(new Action(() => { - AppLogger.LogToGui( - $"{ProductInfo.ProductName} failed to restart automatically after VIIPER install: {ex.Message}", - true, true); - } - }); + Application.Current.Shutdown(); + })); + return true; + } + catch (Exception ex) + { + AppLogger.LogToGui( + $"{ProductInfo.ProductName} failed to restart automatically after VIIPER install: {ex.Message}", + true, true); + return false; + } } private static void ShowInstallerMessage(Window owner, string message, diff --git a/DS4WindowsTests/PendingApplicationRestartTests.cs b/DS4WindowsTests/PendingApplicationRestartTests.cs new file mode 100644 index 0000000..e0999d2 --- /dev/null +++ b/DS4WindowsTests/PendingApplicationRestartTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using DS4Windows; + +namespace DS4WindowsTests; + +/// +/// The ordering fix for +/// issue #12. +/// +/// The bug was not that the restart failed loudly — it was that it looked +/// like it worked. The replacement process started while the original still +/// held the named single-instance event, saw an instance already running, +/// signalled it and exited; the original then finished shutting down, taking +/// the VIIPER backend it owned with it. The user was left with nothing running +/// after a successful install. +/// +/// So the tests below are mostly about what must not happen. +/// The launch is a precondition check, not a best effort: no release, no +/// launch, and the test proves the starter was never invoked rather than that +/// it returned something. +/// +[TestClass] +public class PendingApplicationRestartTests +{ + private const string Executable = @"X:\install\Thrum.exe"; + + [TestMethod] + public void ARestartIsNotQueuedForAnExecutableThatIsNotThere() + { + PendingApplicationRestart restart = new PendingApplicationRestart(); + + Assert.IsFalse(restart.Request(Executable, _ => false)); + Assert.IsFalse(restart.IsRequested); + Assert.IsNull(restart.RequestedExecutable); + } + + [TestMethod] + public void ARestartIsNotQueuedWithoutAPath() + { + PendingApplicationRestart restart = new PendingApplicationRestart(); + + Assert.IsFalse(restart.Request(null, _ => true)); + Assert.IsFalse(restart.Request(" ", _ => true)); + Assert.IsFalse(restart.IsRequested); + } + + [TestMethod] + public void NothingLaunchesWhenNothingAskedForARestart() + { + PendingApplicationRestart restart = new PendingApplicationRestart(); + restart.MarkSingleInstanceReleased(); + + List started = new List(); + Assert.AreEqual(ViiperRestartLaunchOutcome.NotRequested, + restart.Launch(started.Add)); + Assert.AreEqual(0, started.Count); + } + + [TestMethod] + public void AQueuedRestartRefusesToLaunchWhileTheSingleInstanceHandleIsHeld() + { + // This is issue #12 itself. Before the fix, this call started the + // replacement; the replacement then exited on the guard, and the + // shutdown that followed left the machine with no application and no + // backend. + PendingApplicationRestart restart = new PendingApplicationRestart(); + Assert.IsTrue(restart.Request(Executable, _ => true)); + + List started = new List(); + List log = new List(); + + Assert.AreEqual(ViiperRestartLaunchOutcome.SingleInstanceStillHeld, + restart.Launch(started.Add, log.Add)); + Assert.AreEqual(0, started.Count, + "the replacement must not be started while the handle is held"); + Assert.AreEqual(1, log.Count); + StringAssert.Contains(log[0], "single-instance handle is still held"); + } + + [TestMethod] + public void TheReplacementStartsOnceTheHandleHasBeenReleased() + { + PendingApplicationRestart restart = new PendingApplicationRestart(); + Assert.IsTrue(restart.Request(Executable, _ => true)); + restart.MarkSingleInstanceReleased(); + + List started = new List(); + Assert.AreEqual(ViiperRestartLaunchOutcome.Launched, + restart.Launch(started.Add)); + CollectionAssert.AreEqual(new[] { Executable }, started); + } + + [TestMethod] + public void AFailedLaunchAttemptStillCountsAndDoesNotRetryInALoop() + { + PendingApplicationRestart restart = new PendingApplicationRestart(); + restart.Request(Executable, _ => true); + restart.MarkSingleInstanceReleased(); + + List log = new List(); + Assert.AreEqual(ViiperRestartLaunchOutcome.LaunchFailed, + restart.Launch(_ => throw new InvalidOperationException("denied"), + log.Add)); + StringAssert.Contains(log[0], "denied"); + + Assert.AreEqual(ViiperRestartLaunchOutcome.AlreadyLaunched, + restart.Launch(_ => Assert.Fail("must not start a second time"))); + } + + [TestMethod] + public void ASecondLaunchDoesNotStartASecondInstance() + { + PendingApplicationRestart restart = new PendingApplicationRestart(); + restart.Request(Executable, _ => true); + restart.MarkSingleInstanceReleased(); + + List started = new List(); + Assert.AreEqual(ViiperRestartLaunchOutcome.Launched, + restart.Launch(started.Add)); + Assert.AreEqual(ViiperRestartLaunchOutcome.AlreadyLaunched, + restart.Launch(started.Add)); + Assert.AreEqual(1, started.Count); + } + + [TestMethod] + public void ReleasingBeforeTheRequestIsStillAValidOrdering() + { + // The invariant is "released by the time we launch", not a fixed + // sequence of calls; the shutdown path happens to do it the other way + // round and either has to be safe. + PendingApplicationRestart restart = new PendingApplicationRestart(); + restart.MarkSingleInstanceReleased(); + restart.Request(Executable, _ => true); + + List started = new List(); + Assert.AreEqual(ViiperRestartLaunchOutcome.Launched, + restart.Launch(started.Add)); + Assert.AreEqual(1, started.Count); + } + + [TestMethod] + public void AQueuedRestartRemainsQueuedUntilItLaunches() + { + PendingApplicationRestart restart = new PendingApplicationRestart(); + restart.Request(Executable, _ => true); + Assert.IsTrue(restart.IsRequested); + Assert.AreEqual(Executable, restart.RequestedExecutable); + + restart.MarkSingleInstanceReleased(); + restart.Launch(_ => { }); + Assert.IsFalse(restart.IsRequested); + } + + [TestMethod] + public void TheApplicationWideInstanceStartsIdle() + { + // A shutdown that nobody asked to restart must not start anything, and + // this is the instance the real shutdown path drains. + Assert.IsFalse(PendingApplicationRestart.Current.IsRequested); + Assert.AreEqual(ViiperRestartLaunchOutcome.NotRequested, + PendingApplicationRestart.Current.Launch( + _ => Assert.Fail("nothing was queued"))); + } +} diff --git a/DS4WindowsTests/ViiperInstallerPolicyTests.cs b/DS4WindowsTests/ViiperInstallerPolicyTests.cs new file mode 100644 index 0000000..0648bcf --- /dev/null +++ b/DS4WindowsTests/ViiperInstallerPolicyTests.cs @@ -0,0 +1,684 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DS4Windows; + +namespace DS4WindowsTests; + +/// +/// The fail-closed decisions behind extras/install-viiper-backend.ps1. +/// +/// These live in C# rather than in the script for one reason worth +/// restating here: the admission rule is "the manifest decides", the manifest is +/// , and a PowerShell copy of its version +/// table would be the copy that decides whether a kernel driver is installed. +/// Keeping the decisions here means they are covered by the suite that already +/// gates every merge, and the script keeps only the mechanical half — fetching +/// bytes, running installers, swapping files. +/// +/// Every test below is a pure call. Nothing here downloads, installs, +/// elevates, or reads the machine. +/// +[TestClass] +public class ViiperInstallerPolicyTests +{ + private const string PinnedUsbipDigest = + "51620FA5F9F8BE5932BC9D786DEEE557CE06D5407A99CAB490DCFAC71F185FEA"; + + private const string PinnedViiperDigest = + "3AD872D006DF2FC282E381A68B5A5B3C51E4DA3614D250AB3FDA1C272EF745D0"; + + private const string PinnedSigner = "Cloudyne Systems (Scheibling Consulting AB)"; + + // ---------------------------------------------------------------- pins -- + + [TestMethod] + public void ThePinnedUsbipReleaseIsOneTheManifestKnows() + { + // A pin the manifest does not recognise would install a package the + // driver gate then refuses, which is the worst of both designs. + Assert.IsTrue(ViiperDriverManifest.ObservedBaselines.Releases.Any( + release => release.ReleaseLabel == + ViiperInstallerPins.UsbipWin2.ReleaseLabel)); + } + + [TestMethod] + public void TheUsbipPinCarriesTheInspectedIdentity() + { + ViiperPinnedDownload pin = ViiperInstallerPins.UsbipWin2; + Assert.AreEqual("0.9.7.7", pin.ReleaseLabel); + Assert.AreEqual("USBip-0.9.7.7-x64.exe", pin.FileName); + Assert.AreEqual(PinnedUsbipDigest, pin.Sha256); + Assert.IsTrue(pin.RequireAuthenticode); + Assert.AreEqual(PinnedSigner, pin.ExpectedSignerCommonName); + StringAssert.StartsWith(pin.Url, + "https://github.com/vadimgrn/usbip-win2/releases/download/"); + } + + [TestMethod] + public void TheViiperPinIsAnExactAssetAndNotAReleaseQuery() + { + ViiperPinnedDownload pin = ViiperInstallerPins.ViiperBackend; + Assert.AreEqual("v0.0.5", pin.ReleaseLabel); + Assert.AreEqual(PinnedViiperDigest, pin.Sha256); + Assert.AreEqual( + "https://github.com/hbashton/VIIPER/releases/download/v0.0.5/viiper.exe", + pin.Url); + + // Upstream publishes this asset unsigned, so requiring Authenticode + // would fail on every honest download. The digest is the whole + // identity, and the pin has to say so rather than quietly skip a check. + Assert.IsFalse(pin.RequireAuthenticode); + Assert.IsNull(pin.ExpectedSignerCommonName); + } + + [TestMethod] + public void NoPinResolvesAReleaseAtRunTime() + { + foreach (ViiperPinnedDownload pin in ViiperInstallerPins.All) + { + StringAssert.Contains(pin.Url, "/releases/download/", + pin.FileName + " must name one asset, not a release query."); + Assert.IsFalse(pin.Url.Contains("api.github.com"), + pin.FileName + " must not be resolved through the releases API."); + Assert.IsFalse(pin.Url.Contains("/latest"), + pin.FileName + " must never resolve to 'latest'."); + Assert.IsFalse(string.IsNullOrWhiteSpace(pin.DigestProvenance), + pin.FileName + " must record where its digest came from."); + } + } + + [TestMethod] + public void ADigestComparesRegardlessOfHowItWasWrittenDown() + { + ViiperPinnedDownload pin = ViiperInstallerPins.UsbipWin2; + Assert.IsTrue(pin.MatchesDigest(PinnedUsbipDigest.ToLowerInvariant())); + Assert.IsTrue(pin.MatchesDigest("sha256:" + PinnedUsbipDigest)); + Assert.IsTrue(pin.MatchesDigest(" " + PinnedUsbipDigest + " ")); + Assert.IsFalse(pin.MatchesDigest(null)); + Assert.IsFalse(pin.MatchesDigest(string.Empty)); + } + + [TestMethod] + public void AComponentTokenIsParsedOrRejectedButNeverGuessed() + { + Assert.IsTrue(ViiperInstallerPins.TryParseComponent("usbip", + out ViiperInstallerComponent usbip)); + Assert.AreEqual(ViiperInstallerComponent.UsbipWin2, usbip); + + Assert.IsTrue(ViiperInstallerPins.TryParseComponent("VIIPER", + out ViiperInstallerComponent viiper)); + Assert.AreEqual(ViiperInstallerComponent.ViiperBackend, viiper); + + Assert.IsFalse(ViiperInstallerPins.TryParseComponent("usbipp", out _)); + Assert.IsFalse(ViiperInstallerPins.TryParseComponent(null, out _)); + Assert.IsFalse(ViiperInstallerPins.TryParseComponent(" ", out _)); + } + + // ------------------------------------------------- download verification -- + + [TestMethod] + public void ACorrectDigestAndTheExpectedSignerIsApproved() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, GoodUsbipObservation()); + + Assert.AreEqual(ViiperDownloadVerdict.Approved, decision.Action); + AssertLogged(decision, "SHA-256: expected " + PinnedUsbipDigest + + ", actual " + PinnedUsbipDigest + "."); + AssertLogged(decision, "Authenticode signer: expected \"" + + PinnedSigner + "\", actual \"" + PinnedSigner + "\"."); + } + + [TestMethod] + public void AWrongDigestIsRefusedEvenWithAPerfectSignature() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, + With(GoodUsbipObservation(), sha256: new string('A', 64))); + + Assert.AreEqual(ViiperDownloadVerdict.DigestMismatch, decision.Action); + AssertLogged(decision, "SHA-256: expected " + PinnedUsbipDigest + + ", actual " + new string('A', 64) + "."); + } + + [TestMethod] + public void AMissingFileIsUnavailableRatherThanAMismatch() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, + new ViiperDownloadObservation { Exists = false }); + + Assert.AreEqual(ViiperDownloadVerdict.Unavailable, decision.Action); + AssertLogged(decision, "File present: expected yes, actual no."); + } + + [TestMethod] + public void ANullObservationIsUnavailableAndNeverApproved() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, null); + + Assert.AreEqual(ViiperDownloadVerdict.Unavailable, decision.Action); + } + + [TestMethod] + public void AnUncomputableDigestIsUnavailable() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, + With(GoodUsbipObservation(), sha256: null, + observationError: "the file is in use")); + + Assert.AreEqual(ViiperDownloadVerdict.Unavailable, decision.Action); + AssertLogged(decision, "Digest could not be computed: the file is in use."); + } + + [TestMethod] + public void AValidSignatureFromTheWrongPublisherIsRefused() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, + With(GoodUsbipObservation(), signerCommonName: "Some Other Publisher")); + + Assert.AreEqual(ViiperDownloadVerdict.UnexpectedSigner, decision.Action); + AssertLogged(decision, "Authenticode signer: expected \"" + + PinnedSigner + "\", actual \"Some Other Publisher\"."); + } + + [TestMethod] + public void AnUntrustedSignatureIsRefusedBeforeTheSignerIsEvenConsidered() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, + With(GoodUsbipObservation(), signatureTrusted: false, + signatureDiagnostic: "untrusted root (developer/test signature)")); + + Assert.AreEqual(ViiperDownloadVerdict.SignatureNotTrusted, decision.Action); + AssertLogged(decision, "Authenticode chain: expected trusted under " + + "normal Windows policy, actual not trusted (untrusted root " + + "(developer/test signature))."); + Assert.IsFalse(decision.Lines.Any(line => + line.StartsWith("Authenticode signer:", StringComparison.Ordinal))); + } + + [TestMethod] + public void AnAbsentSignatureIsRefused() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, + With(GoodUsbipObservation(), signatureTrusted: false, + signerCommonName: null, + signatureDiagnostic: "no valid signature")); + + Assert.AreEqual(ViiperDownloadVerdict.SignatureNotTrusted, decision.Action); + } + + [TestMethod] + public void ASignatureThatWasNeverEvaluatedIsNotAPass() + { + // The single most dangerous failure shape: a verifier that quietly did + // not run reads exactly like one that ran and found nothing wrong. + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, + With(GoodUsbipObservation(), signatureEvaluated: false)); + + Assert.AreEqual(ViiperDownloadVerdict.Unavailable, decision.Action); + AssertLogged(decision, + "Authenticode: expected a verified chain, actual (not evaluated)."); + } + + [TestMethod] + public void AnUnsignedComponentPassesOnItsDigestAloneAndSaysSo() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.ViiperBackend, + new ViiperDownloadObservation + { + Exists = true, + SizeInBytes = ViiperInstallerPins.ViiperBackend.SizeInBytes, + Sha256 = PinnedViiperDigest, + SignatureEvaluated = false, + }); + + Assert.AreEqual(ViiperDownloadVerdict.Approved, decision.Action); + Assert.IsTrue(decision.Lines.Any(line => line.Contains( + "not required for this component"))); + } + + [TestMethod] + public void AnUnsignedComponentStillFailsOnAWrongDigest() + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.ViiperBackend, + new ViiperDownloadObservation + { + Exists = true, + SizeInBytes = 10, + Sha256 = new string('B', 64), + }); + + Assert.AreEqual(ViiperDownloadVerdict.DigestMismatch, decision.Action); + } + + [TestMethod] + public void EveryVerificationRecordsExpectedBesideActual() + { + foreach (ViiperDownloadObservation observation in new[] + { + GoodUsbipObservation(), + With(GoodUsbipObservation(), sha256: new string('C', 64)), + With(GoodUsbipObservation(), signatureTrusted: false), + }) + { + var decision = ViiperInstallerPolicy.DecideDownloadVerification( + ViiperInstallerPins.UsbipWin2, observation); + Assert.IsTrue(decision.Lines.Any(line => + line.StartsWith("SHA-256: expected ", StringComparison.Ordinal)), + "every verification has to record the digest it compared"); + Assert.IsTrue(decision.Lines.Any(line => + line.StartsWith("Decision: ", StringComparison.Ordinal)), + "every verification has to record its outcome"); + } + } + + // --------------------------------------------------- usbip-win2 decision -- + + [TestMethod] + public void ThePinnedReleaseAlreadyInstalledIsLeftAloneAsAlreadyPinned() + { + var decision = Decide(ViiperDriverReadinessState.ValidatedExperimental, + "0.9.7.7", ViiperDriverTier.ExperimentalBaseline); + + Assert.AreEqual(ViiperUsbipInstallAction.AlreadyPinned, decision.Action); + } + + [TestMethod] + public void ARecognisedNewerReleaseIsReportedAndNeverDowngraded() + { + // The maintainer's own machine: 0.9.7.8, which the manifest knows and + // which is newer than the release setup would install. Reinstalling + // 0.9.7.7 over it would be a kernel-driver downgrade decided by a + // script rather than by the person who installed it. + var decision = Decide(ViiperDriverReadinessState.ValidatedExperimental, + "0.9.7.8", ViiperDriverTier.ExperimentalBaseline); + + Assert.AreEqual(ViiperUsbipInstallAction.LeaveRecognisedReleaseAlone, + decision.Action); + StringAssert.Contains(decision.Summary, "0.9.7.8"); + StringAssert.Contains(decision.Summary, "experimental baseline"); + } + + [TestMethod] + public void AnUnvalidatedInstallIsRefusedRatherThanRepaired() + { + var decision = Decide(ViiperDriverReadinessState.DetectedUnvalidated, + null, null); + + Assert.AreEqual(ViiperUsbipInstallAction.RefuseUnrecognisedInstall, + decision.Action); + StringAssert.Contains(decision.Summary, "will not touch"); + } + + [TestMethod] + public void AnEmptyMachineGetsThePinnedRelease() + { + var decision = Decide(ViiperDriverReadinessState.Missing, null, null); + + Assert.AreEqual(ViiperUsbipInstallAction.InstallPinned, decision.Action); + StringAssert.Contains(decision.Summary, "0.9.7.7"); + } + + [TestMethod] + public void ThePinnedReleaseRegisteredButNotBoundIsReinstalled() + { + // Reboot pending, or a half-finished install. Re-running the same + // pinned release is the repair, and it is not a downgrade. + var decision = Decide(ViiperDriverReadinessState.Missing, null, null, + registered: "0.9.7.7"); + + Assert.AreEqual(ViiperUsbipInstallAction.InstallPinned, decision.Action); + } + + [TestMethod] + public void ADifferentRecognisedReleaseRegisteredButNotBoundIsLeftAlone() + { + var decision = Decide(ViiperDriverReadinessState.Missing, null, null, + registered: "0.9.7.8"); + + Assert.AreEqual(ViiperUsbipInstallAction.LeaveRecognisedReleaseAlone, + decision.Action); + } + + [TestMethod] + public void AVersionTheManifestDoesNotKnowIsRefusedNotAdmitted() + { + foreach (string unlisted in new[] { "0.9.7.9", "1.0.0", "0.9.7.6" }) + { + var decision = Decide(ViiperDriverReadinessState.Missing, null, null, + registered: unlisted); + + Assert.AreEqual(ViiperUsbipInstallAction.RefuseUnrecognisedInstall, + decision.Action, unlisted + " is not in the manifest"); + StringAssert.Contains(decision.Summary, unlisted); + } + } + + [TestMethod] + public void ANewerReleaseIsNeverAdmittedForBeingNewer() + { + // The rule that replaces upstream's "-ge 0.9.7.7" floor. A floor admits + // everything above it; the manifest admits exactly what it lists. + var decision = Decide(ViiperDriverReadinessState.Missing, null, null, + registered: "99.0.0.0"); + + Assert.AreEqual(ViiperUsbipInstallAction.RefuseUnrecognisedInstall, + decision.Action); + } + + [TestMethod] + public void AReleaseLabelMatchesRegardlessOfALeadingV() + { + foreach (string spelling in new[] { "v0.9.7.7", "v.0.9.7.7", " 0.9.7.7 " }) + { + var decision = Decide(ViiperDriverReadinessState.Missing, null, null, + registered: spelling); + Assert.AreEqual(ViiperUsbipInstallAction.InstallPinned, + decision.Action, spelling); + } + } + + [TestMethod] + public void AnApprovedTierMatchIsHandledLikeAnyOtherManifestMatch() + { + var decision = Decide(ViiperDriverReadinessState.Approved, "0.9.7.7", + ViiperDriverTier.Production); + + Assert.AreEqual(ViiperUsbipInstallAction.AlreadyPinned, decision.Action); + } + + [TestMethod] + public void AnUnknownReadinessValueIsTreatedAsUnverifiable() + { + var decision = Decide((ViiperDriverReadinessState)99, "0.9.7.7", + ViiperDriverTier.ExperimentalBaseline); + + Assert.AreEqual(ViiperUsbipInstallAction.RefuseUnrecognisedInstall, + decision.Action); + } + + [TestMethod] + public void EveryUsbipDecisionRecordsThePinAndTheObservedState() + { + foreach (ViiperDriverReadinessState state in + Enum.GetValues(typeof(ViiperDriverReadinessState)) + .Cast()) + { + var decision = Decide(state, "0.9.7.8", + ViiperDriverTier.ExperimentalBaseline); + Assert.IsTrue(decision.Lines.Any(line => + line.StartsWith("usbip-win2 pinned release: 0.9.7.7", + StringComparison.Ordinal)), state.ToString()); + Assert.IsTrue(decision.Lines.Any(line => + line.StartsWith("usbip-win2 installed state: ", + StringComparison.Ordinal)), state.ToString()); + } + } + + // ------------------------------------------------ post-install validation -- + + [TestMethod] + public void APassingDiagnosticValidatesTheInstalledPair() + { + var decision = ViiperInstallerPolicy.DecidePostInstallValidation(true, + ViiperDriverValidationCommand.ExitCodePassed); + + Assert.AreEqual(ViiperPostInstallVerdict.Validated, decision.Action); + } + + [TestMethod] + public void AFailingDiagnosticBlocks() + { + var decision = ViiperInstallerPolicy.DecidePostInstallValidation(true, + ViiperDriverValidationCommand.ExitCodeFailed); + + Assert.AreEqual(ViiperPostInstallVerdict.Refused, decision.Action); + } + + [TestMethod] + public void ADiagnosticThatCouldNotRunIsAFailureAndNotANeutralOutcome() + { + var decision = ViiperInstallerPolicy.DecidePostInstallValidation(true, + ViiperDriverValidationCommand.ExitCodeError); + + Assert.AreEqual(ViiperPostInstallVerdict.CouldNotRun, decision.Action); + StringAssert.Contains(decision.Summary, "treated as a failure"); + } + + [TestMethod] + public void ADiagnosticThatNeverStartedIsAlsoAFailure() + { + // The "the application executable is not next to the script" case. + var decision = ViiperInstallerPolicy.DecidePostInstallValidation(false, 0); + + Assert.AreEqual(ViiperPostInstallVerdict.CouldNotRun, decision.Action); + } + + [TestMethod] + public void AnUndocumentedExitCodeIsNotTrusted() + { + foreach (int exitCode in new[] { -1, 3, 42 }) + { + var decision = ViiperInstallerPolicy.DecidePostInstallValidation( + true, exitCode); + Assert.AreEqual(ViiperPostInstallVerdict.CouldNotRun, + decision.Action, exitCode.ToString()); + } + } + + // ------------------------------------------------------------ exit codes -- + + [TestMethod] + public void OnlyAValidatedPairProducesSuccess() + { + Assert.AreEqual(ViiperInstallerPolicy.ScriptExitSuccess, + ViiperInstallerPolicy.ResolveScriptExitCode( + ViiperPostInstallVerdict.Validated, restartPending: false)); + Assert.AreEqual(ViiperInstallerPolicy.ScriptExitSuccess, + ViiperInstallerPolicy.ResolveScriptExitCode( + ViiperPostInstallVerdict.Validated, restartPending: true)); + } + + [TestMethod] + public void APendingRestartIsItsOwnOutcomeAndNotSuccess() + { + Assert.AreEqual(ViiperInstallerPolicy.ScriptExitRestartRequired, + ViiperInstallerPolicy.ResolveScriptExitCode( + ViiperPostInstallVerdict.CouldNotRun, restartPending: true)); + Assert.AreEqual(ViiperInstallerPolicy.ScriptExitFailed, + ViiperInstallerPolicy.ResolveScriptExitCode( + ViiperPostInstallVerdict.CouldNotRun, restartPending: false)); + Assert.AreEqual(ViiperInstallerPolicy.ScriptExitFailed, + ViiperInstallerPolicy.ResolveScriptExitCode( + ViiperPostInstallVerdict.Refused, restartPending: false)); + } + + [TestMethod] + public void OnlyASuccessfulRunWithAReadyBackendRestartsTheApplication() + { + var report = ViiperInstallerPolicy.DescribeInstallerExit( + ViiperInstallerPolicy.ScriptExitSuccess, ready: true, logPath: null); + + Assert.IsTrue(report.Succeeded); + Assert.IsTrue(report.RestartApplication); + Assert.IsFalse(report.IsError); + } + + [TestMethod] + public void ASuccessfulRunWithoutAReadyBackendDoesNotRestart() + { + var report = ViiperInstallerPolicy.DescribeInstallerExit( + ViiperInstallerPolicy.ScriptExitSuccess, ready: false, logPath: null); + + Assert.IsFalse(report.Succeeded); + Assert.IsFalse(report.RestartApplication); + Assert.IsFalse(report.IsError); + } + + [TestMethod] + public void ARestartRequiredExitAsksForAWindowsRestartAndIsNotAnError() + { + var report = ViiperInstallerPolicy.DescribeInstallerExit( + ViiperInstallerPolicy.ScriptExitRestartRequired, ready: true, + logPath: null); + + Assert.IsFalse(report.Succeeded); + Assert.IsFalse(report.RestartApplication); + Assert.IsFalse(report.IsError); + StringAssert.Contains(report.Message, "Restart Windows"); + } + + [TestMethod] + public void AFailedRunIsAnErrorAndNeverRestartsTheApplication() + { + foreach (int exitCode in new[] { 1, 2, 7 }) + { + var report = ViiperInstallerPolicy.DescribeInstallerExit(exitCode, + ready: true, logPath: @"%LOCALAPPDATA%\VIIPER\install.log"); + + Assert.IsFalse(report.Succeeded, exitCode.ToString()); + Assert.IsFalse(report.RestartApplication, exitCode.ToString()); + Assert.IsTrue(report.IsError, exitCode.ToString()); + StringAssert.Contains(report.Message, "install.log"); + } + } + + [TestMethod] + public void AReadyBackendNeverTurnsAFailedRunIntoASuccess() + { + // Setup can fail after the backend is already answering — a refused + // driver package, for instance. The backend being up is not the + // question the exit code answers. + var report = ViiperInstallerPolicy.DescribeInstallerExit( + ViiperInstallerPolicy.ScriptExitFailed, ready: true, logPath: null); + + Assert.IsFalse(report.Succeeded); + } + + // -------------------------------------------------------------- autostart -- + + [TestMethod] + public void NoAutostartEntryMeansNothingToDoAndSetupCreatesNone() + { + var decision = ViiperInstallerPolicy.PlanAutostartRemoval( + new ViiperAutostartStatus(Array.Empty()), + removalRequested: false); + + Assert.AreEqual(ViiperAutostartPlanAction.NothingToDo, decision.Action); + Assert.IsTrue(decision.Lines.Any(line => line.Contains( + "Setup does not create any"))); + } + + [TestMethod] + public void AnExistingEntryIsOfferedForRemovalAndNotSilentlyAdopted() + { + var decision = ViiperInstallerPolicy.PlanAutostartRemoval( + StatusWithBothEntries(), removalRequested: false); + + Assert.AreEqual(ViiperAutostartPlanAction.OfferRemoval, decision.Action); + Assert.IsTrue(decision.Lines.Any(line => line.Contains( + "Startup registry entry \"VIIPER\""))); + Assert.IsTrue(decision.Lines.Any(line => line.Contains( + "Logon scheduled task \"RunVIIPER\""))); + Assert.IsTrue(decision.Lines.Any(line => line.Contains( + "--update-notify none")), + "the reason these entries matter is the live self-updater"); + } + + [TestMethod] + public void RemovalHappensOnlyWhenItWasAskedFor() + { + var decision = ViiperInstallerPolicy.PlanAutostartRemoval( + StatusWithBothEntries(), removalRequested: true); + + Assert.AreEqual(ViiperAutostartPlanAction.Remove, decision.Action); + StringAssert.Contains(decision.Summary, "2 existing"); + } + + [TestMethod] + public void AnUnreadableAutostartStateIsNeverReportedAsAbsent() + { + foreach (ViiperAutostartStatus status in new[] + { + null, + new ViiperAutostartStatus(Array.Empty(), + "registry: access denied"), + }) + { + var decision = ViiperInstallerPolicy.PlanAutostartRemoval(status, + removalRequested: true); + Assert.AreEqual(ViiperAutostartPlanAction.CouldNotInspect, + decision.Action); + } + } + + // ----------------------------------------------------------------- helpers -- + + private static ViiperInstallerDecision Decide( + ViiperDriverReadinessState state, string matchedRelease, + ViiperDriverTier? tier, string registered = null) => + ViiperInstallerPolicy.DecideUsbipInstall(state, matchedRelease, tier, + registered, ViiperInstallerPins.UsbipWin2, + ViiperDriverManifest.ObservedBaselines); + + private static ViiperDownloadObservation GoodUsbipObservation() => + new ViiperDownloadObservation + { + Exists = true, + SizeInBytes = ViiperInstallerPins.UsbipWin2.SizeInBytes, + Sha256 = PinnedUsbipDigest, + SignatureEvaluated = true, + SignatureTrusted = true, + SignerCommonName = PinnedSigner, + SignatureDiagnostic = "trusted", + }; + + private static ViiperDownloadObservation With( + ViiperDownloadObservation source, string sha256 = "\0", + bool? signatureEvaluated = null, bool? signatureTrusted = null, + string signerCommonName = "\0", string signatureDiagnostic = "\0", + string observationError = "\0") => + new ViiperDownloadObservation + { + Exists = source.Exists, + SizeInBytes = source.SizeInBytes, + Sha256 = sha256 == "\0" ? source.Sha256 : sha256, + SignatureEvaluated = signatureEvaluated ?? source.SignatureEvaluated, + SignatureTrusted = signatureTrusted ?? source.SignatureTrusted, + SignerCommonName = signerCommonName == "\0" + ? source.SignerCommonName : signerCommonName, + SignatureDiagnostic = signatureDiagnostic == "\0" + ? source.SignatureDiagnostic : signatureDiagnostic, + ObservationError = observationError == "\0" + ? source.ObservationError : observationError, + }; + + private static ViiperAutostartStatus StatusWithBothEntries() => + new ViiperAutostartStatus(new List + { + new ViiperAutostartEntry(ViiperAutostartKind.RegistryRunValue, + "VIIPER", @"C:\viiper\viiper.exe server"), + new ViiperAutostartEntry(ViiperAutostartKind.ScheduledTask, + "RunVIIPER", @"C:\viiper\viiper.exe server"), + }); + + private static void AssertLogged(ViiperInstallerDecision decision, + string expected) + { + Assert.IsTrue(decision.Lines.Any(line => + string.Equals(line, expected, StringComparison.Ordinal)), + "expected the decision to record: " + expected + + Environment.NewLine + "actual lines:" + Environment.NewLine + + string.Join(Environment.NewLine, decision.Lines)); + } +} diff --git a/DS4WindowsTests/ViiperInstallerScriptTests.cs b/DS4WindowsTests/ViiperInstallerScriptTests.cs new file mode 100644 index 0000000..ad342b8 --- /dev/null +++ b/DS4WindowsTests/ViiperInstallerScriptTests.cs @@ -0,0 +1,215 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using DS4Windows; + +namespace DS4WindowsTests; + +/// +/// Structural guards on extras/install-viiper-backend.ps1. +/// +/// These are a backstop, and they are the weakest tests in this change +/// set. Matching text in a script proves the text is there, not that the +/// script behaves. The real coverage of the fail-closed logic is +/// , which exercises the actual +/// decision functions; the script was deliberately reduced to orchestration so +/// that almost nothing decidable is left in it. +/// +/// What is left in it is four properties that cannot be expressed +/// anywhere else, because they are properties of the absence of code: that no +/// autostart entry is created, that no backend is started without the update +/// notifier disabled, that no URL or digest is written down outside the pins, +/// and that the rollback backup is not deleted on success. A regression in any +/// of those is silent — the script keeps working, it just stops being safe — +/// so a crude check that fails loudly is worth more than no check at all. +/// +/// Running the script itself is [VM]-gated: it installs a kernel driver. +/// See the plan's Part 3 rule 1. +/// +[TestClass] +public class ViiperInstallerScriptTests +{ + private static string script; + + [ClassInitialize] + public static void LoadScript(TestContext context) + { + string path = FindScript(); + Assert.IsNotNull(path, + "extras/install-viiper-backend.ps1 was not found above " + + AppContext.BaseDirectory + "."); + script = File.ReadAllText(path); + } + + [TestMethod] + public void TheScriptLooksForTheExecutableThisBuildActuallyProduces() + { + // The script cannot read a C# constant, so this coupling is the one + // place ProductInfo and the script have to be kept in step by hand. + StringAssert.Contains(script, + "$script:DefaultAppExecutableName = \"" + + ProductInfo.ExeBaseName + ".exe\""); + } + + [TestMethod] + public void TheScriptCreatesNoAutostartEntry() + { + // Both mechanisms, gone: the RunVIIPER logon task and the HKCU Run + // value that "viiper.exe install" writes. Thrum starts the backend when + // a profile needs it and stops it on exit (plan task 2.4b), so a logon + // entry would start a backend the application never owns and never + // stops. + foreach (string forbidden in new[] + { + "Register-ScheduledTask", + "New-ScheduledTaskAction", + "New-ScheduledTaskTrigger", + "New-ScheduledTaskPrincipal", + "schtasks", + "ArgumentList \"install\"", + "New-ItemProperty", + "Set-ItemProperty", + "CurrentVersion\\Run\\", + }) + { + Assert.IsFalse(script.Contains(forbidden, StringComparison.OrdinalIgnoreCase), + "the setup script must not contain '" + forbidden + "'."); + } + } + + [TestMethod] + public void TheTaskNameAppearsOnlyWhereRemovalIsDocumented() + { + // The name is allowed to be written down — the script has to be able to + // say what -RemoveViiperAutostart removes. It is not allowed to appear + // anywhere a command could act on it, which the creation-verb ban above + // covers; this pins the remaining occurrence so a registration cannot + // be reintroduced under cover of the documentation. + MatchCollection matches = Regex.Matches(script, ".*RunVIIPER.*"); + Assert.AreEqual(1, matches.Count, + "RunVIIPER may appear once, in the -RemoveViiperAutostart help."); + StringAssert.Contains(matches[0].Value, "task). Without this"); + } + + [TestMethod] + public void TheScriptOffersToRemoveAnAutostartEntryItDidNotCreate() + { + StringAssert.Contains(script, "[switch]$RemoveViiperAutostart"); + StringAssert.Contains(script, "$autostartArgs += \"--remove\""); + } + + [TestMethod] + public void EveryBackendStartDisablesTheUpdateNotifier() + { + // Issue #8's remaining half. The argument vector comes from the same + // constant the application spawns with, so the script cannot drift from + // it, and a literal "server" would be exactly that drift. + StringAssert.Contains(script, "-ArgumentList $serverArgs"); + Assert.IsFalse(script.Contains("-ArgumentList \"server\"", + StringComparison.Ordinal), + "the backend must never be started with a hand-written argument list."); + StringAssert.Contains(script, "$pins['viiper.serverargs']"); + } + + [TestMethod] + public void TheScriptResolvesNoReleaseAndHardCodesNoArtefactIdentity() + { + foreach (string forbidden in new[] + { + "api.github.com", + "github.com", + "per_page", + "browser_download_url", + "-ge $requiredUsbipVersion", + }) + { + Assert.IsFalse(script.Contains(forbidden, StringComparison.OrdinalIgnoreCase), + "the setup script must not contain '" + forbidden + + "': URLs, digests and admissible versions come from the pins."); + } + } + + [TestMethod] + public void NothingIsExecutedBeforeItHasBeenVerified() + { + int verified = script.IndexOf("Get-VerifiedPinnedFile \"usbip\"", + StringComparison.Ordinal); + int executed = script.IndexOf("-ArgumentList \"/S\"", + StringComparison.Ordinal); + + Assert.IsTrue(verified >= 0, "the driver installer must be verified."); + Assert.IsTrue(executed >= 0, "the driver installer is still run silently."); + Assert.IsTrue(verified < executed, + "the pinned installer must be verified before it is executed."); + + Assert.AreEqual(1, Regex.Matches(script, + Regex.Escape("-ArgumentList \"/S\"")).Count, + "there must be exactly one place the driver installer is run."); + } + + [TestMethod] + public void TheRollbackBackupSurvivesASuccessfulInstall() + { + // Upstream deletes viiper.exe.previous once the API answers, which + // leaves rollback available only inside the install window. The failure + // this protects against is a backend that installs cleanly and then + // misbehaves. + Assert.IsFalse(Regex.IsMatch(script, + @"Remove-Item\s+-LiteralPath\s+\$backupPath"), + "the .previous backup must be kept after a successful install."); + StringAssert.Contains(script, "was kept at $backupPath for rollback"); + } + + [TestMethod] + public void AMissingOrUnusableVerificationHelperStopsSetup() + { + // Fail-closed: no helper, no verification, no install. Silence is never + // read as approval. + StringAssert.Contains(script, "rather than installing anything unverified"); + StringAssert.Contains(script, "produced no result"); + StringAssert.Contains(script, "does not match its exit code"); + } + + [TestMethod] + public void AnUnrecognisedPolicyActionStopsSetup() + { + StringAssert.Contains(script, "unrecognised usbip-win2 "); + StringAssert.Contains(script, "rather than guessing"); + } + + [TestMethod] + public void AStagedFileReplacesTheDownloadAndNothingElse() + { + // The VM run sheet's negative cases stage a corrupted or wrongly-signed + // artefact. That has to travel the real path, so the staged file is + // handed to the same verifier by the same call — there is no second, + // laxer branch for it. + StringAssert.Contains(script, "[string]$UsbipInstallerFile"); + StringAssert.Contains(script, "[string]$ViiperBackendFile"); + Assert.AreEqual(2, Regex.Matches(script, + Regex.Escape("Get-VerifiedPinnedFile \"")).Count, + "both components must be fetched through the verifying helper."); + Assert.AreEqual(1, Regex.Matches(script, + @"\$verification\s*=\s*Invoke-InstallerPolicy").Count, + "there must be exactly one verification call site, shared by the " + + "download path and the staged-file path."); + } + + private static string FindScript() + { + DirectoryInfo directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, "extras", + "install-viiper-backend.ps1"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + return null; + } +} diff --git a/docs/dev/PLAN-PROGRESS.md b/docs/dev/PLAN-PROGRESS.md index 1e9a633..6b017ac 100644 --- a/docs/dev/PLAN-PROGRESS.md +++ b/docs/dev/PLAN-PROGRESS.md @@ -2848,3 +2848,186 @@ No new key, so the neutral-only policy for new keys does not apply here. Suite: **696 passed / 0 failed** (CI filter), unchanged from baseline — the pass is itself the check, since the key is listed as XAML-reachable with no known-missing entry, so a null lookup would fail the test. + +--- + +## 2026-07-26 — Phase 2.4: installer hardening (pins, verification, autostart removal, issue #12) + +**Session scope:** plan task **2.4**, written on top of the upstream merge +analysed in `upstream-delta-2026-07-26.md`. Nothing in this session installed, +upgraded, removed or touched a driver, service, scheduled task, registry Run +value or the VIIPER install directory. The setup script was never executed; it +was parsed (AST only), and the decision layer it consults was exercised +directly. + +### The shape of the change, and why + +Upstream's four commits left four of the seven 2.4 requirements entirely ours, +and the honest reading of the remaining three is that the script was making +security decisions it had no way to make well: a `-ge 0.9.7.7` floor against a +version probe that reads the FileVersion of `usbip2_ude.sys` — a DriverVer such +as `1.45.29.368`, which is not the release label and compares greater than every +floor anyone would write. The floor passed trivially on every install it has +ever seen, including this machine's. + +So the decisions moved into C# and the script kept the mechanical half. The +deciding argument is not "C# is nicer to test": it is that the admission rule is +*the manifest decides*, the manifest is `ViiperDriverManifest`, and its own +contract says it must not be duplicated into the UI, the broker or the +installer. A PowerShell copy of the version table would have been exactly that +duplicate — and it would have been the copy deciding whether a kernel driver +gets installed. + +New surface: + +| File | What it owns | +|---|---| +| `Viiper/Validation/ViiperInstallerPins.cs` | The two exact artefacts setup may fetch: URL, SHA-256, size, whether Authenticode is required and from whom, and **how the digest was obtained**. | +| `Viiper/Validation/ViiperInstallerPolicy.cs` | Pure, total decisions: download verdict, usbip install action, post-install verdict, script exit code, the app-side reading of that exit code, autostart plan. | +| `Viiper/Validation/ViiperInstallerPolicyCommand.cs` | The read-only `-viiperinstallerpolicy` verb surface the script consults. | +| `DS4Control/PendingApplicationRestart.cs` | Issue #12's ordering, enforced rather than commented. | + +### Testing approach, and why it is not Pester + +The brief offered (a) dot-sourceable script functions plus a Pester suite in CI, +or (b) decisions in testable C# with the script as thin orchestration, and asked +for whichever puts the fail-closed logic under real tests. + +(b), for three reasons. The manifest argument above is the first and decisive +one. Second, MSTest already gates every merge here; a Pester job would be a +second harness, a second runner dependency and a second place a filter can go +stale, bought for logic that would still have to reach into C# for the version +table. Third — and this is the part worth stating plainly — the *shape* of (b) +is what makes the properties testable at all: `DecideDownloadVerification` is a +function from observed facts to a verdict, so "valid signature, unexpected +subject" is three lines of test instead of a signing fixture. + +67 new tests, all pure: + +- `ViiperInstallerPolicyTests` (46) — correct digest; wrong digest; missing + file; null observation; uncomputable digest; valid signature with an + unexpected subject; untrusted signature; absent signature; **a signature that + was never evaluated** (the failure shape that reads exactly like a pass); + unsigned component approved on its digest alone and refused on a wrong one; + version not in the manifest (three spellings); version newer than pinned; + already-installed pinned version; already-installed recognised-but-different + version; registered-but-not-bound in all four flavours; unknown enum value; + post-install 0/1/2, an undocumented code, and never-started; every exit-code + mapping in both directions; the autostart plan including an unreadable state. +- `PendingApplicationRestartTests` (10) — the #12 ordering, below. +- `ViiperInstallerScriptTests` (11) — **the weakest tests here, and labelled as + such in the file.** Matching text in a script proves the text is there. They + exist for the four properties that are properties of *absent* code — no + autostart creation, no backend start without the update flag, no URL or digest + outside the pins, no deletion of the rollback backup — where a regression is + silent: the script keeps working, it just stops being safe. + +### Requirement by requirement + +1. **Pinned + digest + Authenticode before execution.** `Get-VerifiedPinnedFile` + is the only way an artefact reaches disk, and its next statement is the + verification call. Refusal deletes the file and throws. Verified live against + the genuine signed installer: digest and subject both matched and were logged + expected-beside-actual; a one-byte-flipped copy was refused with both digests + in the log. +2. **Post-install validation of the pair.** A `validate-installed` verb runs + `ViiperDriverValidationCommand.RunDiagnostic()` — the same implementation and + the same 0/1/2 the `-viiperdriverdiagnostic` switch runs — and the script + branches on it. Deliberately not launched as `-viiperdriverdiagnostic` in a + second process: that switch prints to an attached parent console and, when + there is none, opens a **modal report window**. A setup step that can block + on a dialog nobody can see is not a verification step. Exit 2 and "could not + run at all" are both failures. +3. **No silent acceptance of an unlisted release.** The floor is gone; the + primary input is the gate's four-state answer, not a file version. Verified + live on this machine: `readiness=ValidatedExperimental`, + `matchedrelease=0.9.7.8`, `action=LeaveRecognisedReleaseAlone` — + *"It is a release this build recognises as an experimental baseline, and it + is left exactly as it is."* Nothing was installed over it and no downgrade + was attempted. VIIPER is pinned to an exact asset by version **and** digest; + `Get-GithubReleaseAsset` and the newest-non-draft walk are deleted. +4. **Atomic install, rollback retained.** `.previous` is no longer deleted on + success, and its path is logged. Rollback that exists only inside the install + window is not rollback — the failure it guards against is a backend that + installs cleanly and then misbehaves. +5. **Log every decision.** Every decision function returns its audit lines + together with its verdict, from the same call, so the two cannot disagree. + The script copies every `log=` line into `install.log` verbatim. +6. **Both autostart mechanisms removed.** `viiper.exe install` and + `Register-ViiperRunTask` are gone, and with them the `$registrationSafeToRun` + dance they were load-bearing for. `Stop-ViiperProcesses` survives, now needed + only by the atomic install, keeping upstream's retry/escalate/fail-closed + behaviour verbatim. A pre-existing entry is detected through 2.4b's read-only + detector, reported, and removed only with `-RemoveViiperAutostart` or the + Settings button — never adopted. +7. **Issue #8 closed on every path.** `Start-AndVerifyViiper` takes its argument + vector from `ViiperBackendSpawn.ServerArguments` (via the `pins` verb) rather + than spelling out `server`, so the script cannot drift from the application, + and it sets `VIIPER_UPDATE_NOTIFY` as well. With both autostart entries gone, + no path remains that starts an update-nagging backend. +8. **Issue #12 fixed.** Below. + +### Issue #12: the ordering, and what happens to the backend + +`RestartApplication` no longer starts anything. It records intent; +`CleanShutdown` starts the replacement **after** `threadComEvent.Close()`, and +`PendingApplicationRestart.Launch` refuses outright until +`MarkSingleInstanceReleased()` has been called. The ordering is a precondition, +not a comment: an edit that moves the launch earlier fails a test that says why. + +**The backend across the restart is deliberately not special-cased.** +Stop-on-exit runs as usual, the owned backend goes down with the app, and the +new instance starts a fresh one on demand. The alternative — exempting an +install-driven restart — would leave a backend running that the new instance +does not own and would therefore never stop, turning a temporary special case +into a permanent orphan. A few hundred milliseconds of downtime during a restart +nobody is playing through is the cheaper side of that trade. + +### Verification + +- `dotnet build DS4WindowsWPF.sln -c Release -p:Platform=x64` — **0 errors**, 14 + pre-existing warnings. +- Full suite with the CI filter — **763 passed / 0 failed**, from the 696 + baseline (+67). `AppSettingsTests.CheckSettingsSave` remains excluded and + remains stale for the reason recorded in the 2.4b entry. +- Script **parsed** with `[Parser]::ParseFile` — 0 errors, 2,609 tokens, five + declared parameters. Parsing is not execution; the script was never run. +- **Live read-only pass** of every policy verb against the packaged build, using + the genuine artefacts already retained in the workspace. Results as quoted + above, plus: the pinned VIIPER asset approved on its digest with the + "unsigned upstream" line; a missing file reported as `Unavailable` rather than + as a mismatch; `validate-installed` returning `Validated`; `autostart` + returning `NothingToDo` with count 0, matching this machine's known state. + +### Deviations + +1. **Post-install validation goes through `validate-installed`, not a second + `-viiperdriverdiagnostic` process.** Same implementation, same exit codes, no + modal-dialog hazard. Recorded because the brief named the switch. +2. **VIIPER is pinned to the public v0.0.5 asset, not to a bundled copy.** The + plan allowed bundling ours until hbashton/VIIPER#3 lands. Unnecessary: the + public asset has a stable digest that two independent sources agree on, and + pinning by digest already immunises us against the mis-stamp. The mis-stamped + embedded version is recorded in the pin so nothing ever validates by it. + Bundling stays available for Phase 5.2 through `-ViiperBackendFile`. +3. **`-UsbipInstallerFile` / `-ViiperBackendFile` added.** Not in the brief. + They let the VM run sheet's negative cases travel the real code path rather + than a parallel one, and they are not a bypass: a staged file replaces the + download and nothing else, verified by the same call against the same pin. +4. **A third exit code (3) exists.** "Installed, but the pair cannot be + validated until Windows restarts" is neither success nor failure, and + collapsing it into either would have meant lying in one direction. +5. **Not verified, and cannot be here:** the script end to end. Running it + installs a kernel driver, which Part 3 rule 1 puts behind a TESTENV + checkpoint. The decision layer it consults is verified live; the + orchestration around it is source-level only. + +### For the VM run sheet + +`PHASE2-VM-VALIDATION-PREP-20260726.md` Phase B needs no change to be runnable, +and gains a cheaper route: B1 and B2 can be done **without installing anything** +by calling `Thrum.exe -viiperinstallerpolicy verify-file --component usbip +--path --out ` directly — that is the exact call the script +gates on. To exercise them through the script instead, pass +`-UsbipInstallerFile `. B4's "no autostart was created" assertion is now +also covered by a unit test, but the live enumeration is still worth capturing. diff --git a/extras/install-viiper-backend.ps1 b/extras/install-viiper-backend.ps1 index 00bbd96..539aefc 100644 --- a/extras/install-viiper-backend.ps1 +++ b/extras/install-viiper-backend.ps1 @@ -1,16 +1,80 @@ +<# +.SYNOPSIS + Installs or repairs the VIIPER backend, and — only on explicit terms — the + usbip-win2 kernel driver it depends on. + +.DESCRIPTION + Every decision that can end with something being executed, installed or + replaced is made by the application, not by this script. The script fetches + bytes, runs installers and swaps files; Thrum's installer policy decides + whether it may. That split exists because the release manifest, the pinned + digests and the driver gate already live in the application, are covered by + its test suite, and must not be duplicated into a copy that then gets to + decide whether a kernel driver is installed. + + Consequences worth stating plainly: + + * Nothing downloaded is executed before its SHA-256 — and, where the + publisher signs, its Authenticode chain and signer — have been checked + against a pinned identity. + * Nothing newer is accepted just because it is newer. A usbip-win2 release + this build does not recognise is left exactly as it is, and setup says + so rather than "repairing" it. + * The package pair Windows actually bound is validated after the driver + step, through the same gate as the -viiperdriverdiagnostic switch. + * No autostart entry is created. Thrum starts the backend when a profile + needs it and stops it on exit. A pre-existing entry is reported, never + adopted, and removed only when asked. + * Every backend this script starts is started with the update notifier + disabled. + +.PARAMETER NoPause + Do not wait for a key press at the end. Passed by Thrum. + +.PARAMETER RemoveViiperAutostart + Remove any pre-existing VIIPER logon entry (the HKCU Run value and/or the + RunVIIPER task). Without this, an existing entry is only reported. + +.PARAMETER AppExecutable + Full path to the application executable that provides the installer policy. + Defaults to the executable next to this script's parent folder. Setup + refuses to continue without it: it is what performs every verification. + +.PARAMETER UsbipInstallerFile + Use an already-downloaded usbip-win2 installer instead of fetching it. The + file is verified against the pin exactly as a download would be, so this is + an offline convenience and a test hook, never a way past a check. + +.PARAMETER ViiperBackendFile + The same, for the VIIPER backend executable. + +.NOTES + Exit codes: 0 success (driver pair validated), 1 refused or failed, + 3 installed but validation deferred until Windows restarts. +#> param( - [switch]$NoPause + [switch]$NoPause, + [switch]$RemoveViiperAutostart, + [string]$AppExecutable, + [string]$UsbipInstallerFile, + [string]$ViiperBackendFile ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" $script:ExitCode = 0 $script:RebootRecommended = $false +$script:DriverValidated = $false +$script:Refused = $false $script:InstallDir = Join-Path $env:LOCALAPPDATA "VIIPER" $script:LogPath = Join-Path $script:InstallDir "install.log" $script:TempDir = Join-Path ([IO.Path]::GetTempPath()) ( "Thrum-VIIPER-Setup-" + [Guid]::NewGuid().ToString("N")) +# Kept in step with ProductInfo.ExeBaseName by a guard test; this script cannot +# read a C# constant, and the executable is what makes every check possible. +$script:DefaultAppExecutableName = "Thrum.exe" + function Write-SetupLog([string]$message, [ConsoleColor]$color = [ConsoleColor]::Gray) { $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" @@ -34,18 +98,45 @@ function Test-Administrator { [Security.Principal.WindowsBuiltInRole]::Administrator) } -function Get-UsbipInstalledVersion { - $driverPath = Join-Path $env:SystemRoot "System32\drivers\usbip2_ude.sys" - if (Test-Path -LiteralPath $driverPath) { - try { - $versionText = (Get-Item -LiteralPath $driverPath). - VersionInfo.FileVersion - $version = ConvertTo-VersionFromObject $versionText - if ($version) { return $version } +function ConvertTo-VersionFromObject([object]$value) { + if ($null -eq $value) { return $null } + if ($value -is [Version]) { return $value } + + try { + if ($value -is [string]) { + $text = $value.Trim() + } + else { + $text = [string]$value + if ($null -eq $text) { return $null } + $text = $text.Trim() } - catch { } } + catch { return $null } + + if ($text.Length -eq 0) { return $null } + + $parsed = $null + if ([Version]::TryParse($text, [ref]$parsed)) { + return $parsed + } + + return $null +} +<# + The only usbip-win2 probe this script still performs, and it answers one + narrow question: does an uninstall entry claim a release label? + + It is deliberately not used to decide anything. The release label is a hint + that something is registered even when no packages are bound; the identity + decision belongs to the driver gate, which reads the packages Windows + actually loaded. Reading usbip2_ude.sys's FileVersion, as this script used + to, answers neither question: that file carries a DriverVer such as + 1.45.29.368, which is not the 0.9.7.x release label and compares greater + than every floor anyone would write. +#> +function Get-UsbipRegisteredRelease { foreach ($root in @( "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" @@ -60,37 +151,82 @@ function Get-UsbipInstalledVersion { Select-Object -First 1 if ($entry -and $entry.DisplayVersion) { $version = ConvertTo-VersionFromObject $entry.DisplayVersion - if ($version) { return $version } + if ($version) { return $version.ToString() } + return ([string]$entry.DisplayVersion).Trim() } } - return $null + return "" } -function ConvertTo-VersionFromObject([object]$value) { - if ($null -eq $value) { return $null } - if ($value -is [Version]) { return $value } - - try { - if ($value -is [string]) { - $text = $value.Trim() - } - else { - $text = [string]$value - if ($null -eq $text) { return $null } - $text = $text.Trim() - } +function Resolve-AppExecutable([string]$explicitPath) { + if ($explicitPath) { + if (Test-Path -LiteralPath $explicitPath) { return $explicitPath } + throw "The application executable was not found at '$explicitPath'." } - catch { return $null } - if ($text.Length -eq 0) { return $null } + $candidate = Join-Path (Split-Path -Parent $PSScriptRoot) ` + $script:DefaultAppExecutableName + if (Test-Path -LiteralPath $candidate) { return $candidate } - $parsed = $null - if ([Version]::TryParse($text, [ref]$parsed)) { - return $parsed - } + throw ( + "Setup could not find $($script:DefaultAppExecutableName) next to the " + + "'extras' folder. That executable performs every digest, signature and " + + "driver-package check this script depends on, so setup stops here " + + "rather than installing anything unverified. Run setup from inside the " + + "application, or pass -AppExecutable.") +} - return $null +<# + Runs one installer-policy verb and returns its exit code plus the key/value + result. Fail-closed at every step: a missing helper, a missing result file, + an unreadable result, or a result whose reported exit code disagrees with + the process exit code all throw. Setup never proceeds on silence. +#> +function Invoke-InstallerPolicy([string[]]$policyArgs) { + $outFile = Join-Path $script:TempDir ( + "policy-" + [Guid]::NewGuid().ToString("N") + ".txt") + $arguments = @("-viiperinstallerpolicy") + $policyArgs + @("--out", $outFile) + + $quoted = @() + foreach ($argument in $arguments) { + if ($argument -match '\s') { $quoted += ('"' + $argument + '"') } + else { $quoted += $argument } + } + + $process = Start-Process -FilePath $script:AppExecutable ` + -ArgumentList $quoted -Wait -PassThru -WindowStyle Hidden + $exitCode = $process.ExitCode + + if (-not (Test-Path -LiteralPath $outFile)) { + throw ( + "The verification helper produced no result for " + + "'$($policyArgs -join ' ')' (exit code $exitCode). Setup cannot " + + "continue without one.") + } + + $data = @{} + $reportedExit = $null + foreach ($line in [IO.File]::ReadAllLines($outFile, + [Text.Encoding]::UTF8)) { + if ([string]::IsNullOrEmpty($line)) { continue } + $separator = $line.IndexOf('=') + if ($separator -lt 1) { continue } + $key = $line.Substring(0, $separator) + $value = $line.Substring($separator + 1) + if ($key -eq "log") { Write-SetupLog $value } + elseif ($key -eq "exitcode") { $reportedExit = $value } + else { $data[$key] = $value } + } + + if ($reportedExit -ne ([string]$exitCode)) { + throw ( + "The verification helper's result does not match its exit code " + + "(reported '$reportedExit', process $exitCode). Setup treats that " + + "as unverified and stops.") + } + + return @{ ExitCode = $exitCode; Data = $data } } function Invoke-Download([string]$url, [string]$outFile) { @@ -116,88 +252,55 @@ function Invoke-Download([string]$url, [string]$outFile) { throw "Download failed after three attempts: $($lastError.Message)" } -function Get-GithubReleaseAsset([string]$repo, [string]$assetPattern) { - $apiUrl = "https://api.github.com/repos/$repo/releases?per_page=20" - $releases = Invoke-RestMethod -Uri $apiUrl -TimeoutSec 30 -Headers @{ - "User-Agent" = "Thrum-VIIPER-Setup" - "Accept" = "application/vnd.github+json" - } - if (-not $releases) { throw "No releases were found in $repo." } - - foreach ($release in @($releases | Where-Object { -not $_.draft })) { - $asset = @($release.assets) | - Where-Object { $_.name -match $assetPattern } | - Sort-Object @{ Expression = { - if ($_.name -match - '(?i)^viiper-(windows|win)-(amd64|x64)\.zip$') { 0 } - elseif ($_.name -match '(?i)^viiper\.exe$') { 1 } - elseif ($_.name -match - '(?i)(windows|win).*(amd64|x64).*\.(exe|zip)$') { 2 } - elseif ($_.name -match '(?i)\.(exe|zip)$') { 3 } - else { 4 } - }}, name | Select-Object -First 1 - if ($asset) { - $label = if ($release.tag_name) { $release.tag_name } - elseif ($release.name) { $release.name } else { $release.id } - Write-SetupLog ( - "Using '$($asset.name)' from $repo release '$label'.") - return $asset.browser_download_url - } +<# + Fetches a pinned artefact and hands it to the verifier before anything is + done with it. There is no code path that returns an unverified file: a + refusal deletes the download and throws. + + A caller-supplied local file takes the place of the download and nothing + else. It is copied in and verified against the same pin by the same call, + so staging a corrupted or wrongly-signed artefact exercises the refusal + rather than bypassing it — which is exactly what the VM run sheet's + negative cases need. +#> +function Get-VerifiedPinnedFile([string]$component, [hashtable]$pins, + [string]$destination, [string]$stagedFile) { + $url = $pins["$component.url"] + $expected = $pins["$component.sha256"] + if (-not $url -or -not $expected) { + throw "No pinned download is defined for '$component'." } - $names = @($releases | ForEach-Object { $_.assets } | - ForEach-Object { $_.name }) -join ", " - throw "No supported Windows VIIPER asset was found. Assets seen: $names" -} - -function Get-ViiperAssetUrl { - $errors = @() - foreach ($repo in @("hbashton/VIIPER")) { - try { - Write-SetupLog "Checking release assets in $repo" - return Get-GithubReleaseAsset $repo ( - "(?i)^(?!.*(libviiper|client|headers|linux|arm64|\.nupkg|" + - "\.crate|\.tgz)).*\.(exe|zip)$") - } - catch { - $errors += "${repo}: $($_.Exception.Message)" - Write-SetupLog "Could not use ${repo}: $($_.Exception.Message)" Yellow - } - } - throw "Could not locate VIIPER. $($errors -join '; ')" -} + Write-SetupLog ( + "Pinned $component release $($pins["$component.release"]): " + + "$($pins["$component.filename"]), expected SHA-256 $expected.") -function Expand-ViiperAsset([string]$assetUrl, [string]$candidatePath) { - $extension = [IO.Path]::GetExtension(([Uri]$assetUrl).AbsolutePath) - $downloadPath = Join-Path $script:TempDir ("viiper-download" + $extension) - Invoke-Download $assetUrl $downloadPath - - if ($extension -ieq ".exe") { - Copy-Item -LiteralPath $downloadPath -Destination $candidatePath -Force - } - elseif ($extension -ieq ".zip") { - $extractDir = Join-Path $script:TempDir "viiper-extract" - Expand-Archive -LiteralPath $downloadPath -DestinationPath $extractDir ` - -Force - $executable = Get-ChildItem -LiteralPath $extractDir -Recurse ` - -Filter "viiper.exe" | Select-Object -First 1 - if (-not $executable) { - throw "The VIIPER archive did not contain viiper.exe." + if ($stagedFile) { + if (-not (Test-Path -LiteralPath $stagedFile)) { + throw "The staged $component file '$stagedFile' was not found." } - Copy-Item -LiteralPath $executable.FullName ` - -Destination $candidatePath -Force + Write-SetupLog ( + "Using a staged local file instead of downloading. It is verified " + + "against the same pin.") + Copy-Item -LiteralPath $stagedFile -Destination $destination -Force } else { - throw "Unsupported VIIPER asset type '$extension'." + Invoke-Download $url $destination } - $candidate = Get-Item -LiteralPath $candidatePath - if ($candidate.Length -lt 65536) { - throw "The downloaded VIIPER executable is unexpectedly small." - } - if ($candidate.Extension -ine ".exe") { - throw "The downloaded VIIPER payload is not a Windows executable." + $verification = Invoke-InstallerPolicy @( + "verify-file", "--component", $component, "--path", $destination) + if ($verification.ExitCode -ne 0) { + try { + Remove-Item -LiteralPath $destination -Force -ErrorAction SilentlyContinue + } + catch { } + throw ( + "$($verification.Data['summary']) " + + "The downloaded file was discarded and nothing was run from it.") } + + Write-SetupLog $verification.Data['summary'] Green } function Install-ViiperAtomically([string]$candidatePath, @@ -241,6 +344,17 @@ function Get-RunningViiperProcesses { } } +<# + Stops every viiper.exe on the machine, retrying and escalating, and returns + false rather than pretending. + + Worth knowing next to Thrum's runtime policy, which is the opposite: at + runtime the application refuses to stop a backend it did not start or that + is hosting a device. Here the rule is different on purpose — an install is + an explicit, elevated, user-initiated act, and a running image cannot be + replaced on Windows while it is held. The two policies are not in conflict; + they answer different questions. +#> function Stop-ViiperProcesses([string]$operation) { $attempts = 12 for ($attempt = 1; $attempt -le $attempts; $attempt++) { @@ -316,54 +430,40 @@ function Test-ViiperApi([int]$timeoutMilliseconds = 1000) { finally { if ($client) { $client.Dispose() } } } -function Start-AndVerifyViiper([string]$viiperPath) { +<# + Starts the backend for the verification step with the update notifier + disabled. + + The argument vector is not written here: it comes from the same constant the + application spawns with. VIIPER's bundled updater still points at the parent + project's releases and its "Update Now" pipes a remote script into an + elevated shell, so every path that starts a backend has to disable it — + including this one, which is not an autostart entry and was missed by the + runtime fix (issue #8). +#> +function Start-AndVerifyViiper([string]$viiperPath, [hashtable]$pins) { if (Test-ViiperApi) { return $true } - Start-Process -FilePath $viiperPath -ArgumentList "server" ` - -WindowStyle Hidden | Out-Null - for ($attempt = 0; $attempt -lt 10; $attempt++) { - Start-Sleep -Milliseconds 500 - if (Test-ViiperApi) { return $true } - } - return $false -} -function Register-ViiperRunTask([string]$viiperPath, [string]$taskName) { - try { - $taskAction = New-ScheduledTaskAction -Execute $viiperPath ` - -Argument "server" - $taskTrigger = New-ScheduledTaskTrigger -AtLogOn - $taskPrincipal = New-ScheduledTaskPrincipal ` - -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) ` - -RunLevel Highest -LogonType Interactive - $taskSettings = New-ScheduledTaskSettingsSet ` - -AllowStartIfOnBatteries ` - -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) ` - -MultipleInstances IgnoreNew - - Register-ScheduledTask -TaskName $taskName -Action $taskAction ` - -Trigger $taskTrigger -Principal $taskPrincipal -Settings $taskSettings ` - -Force | Out-Null - return $true - } - catch { - Write-SetupLog "Failed modern scheduled task registration: $($_.Exception.Message)" Yellow + $serverArgs = $pins['viiper.serverargs'] + if (-not $serverArgs) { + throw "The backend start arguments were not reported by the policy helper." } - try { - $runCommand = '"{0}" server' -f $viiperPath - $scheduledResult = Start-Process -FilePath "schtasks.exe" ` - -ArgumentList "/Create /F /TN `"$taskName`" /SC ONLOGON /RL HIGHEST /IT /TR `"$runCommand`"" ` - -WindowStyle Hidden -PassThru -Wait - if ($scheduledResult.ExitCode -eq 0) { - return $true - } - - Write-SetupLog "Fallback schtasks command exited with code $($scheduledResult.ExitCode)." Yellow - } - catch { - Write-SetupLog "Failed fallback scheduled task registration: $($_.Exception.Message)" Yellow + Write-SetupLog "Starting the backend for verification: viiper.exe $serverArgs" + $environmentName = $pins['viiper.updatenotifyenv'] + $environmentValue = $pins['viiper.updatenotifyvalue'] + if ($environmentName) { + # Belt and braces, exactly as the application does it: the flag is what + # takes effect, the variable is what a re-exec would inherit. + Set-Item -Path ("Env:" + $environmentName) -Value $environmentValue } + Start-Process -FilePath $viiperPath -ArgumentList $serverArgs ` + -WindowStyle Hidden | Out-Null + for ($attempt = 0; $attempt -lt 10; $attempt++) { + Start-Sleep -Milliseconds 500 + if (Test-ViiperApi) { return $true } + } return $false } @@ -378,77 +478,109 @@ try { Write-SetupLog "Thrum VIIPER virtual controller setup" Green Write-SetupLog "Installing or repairing VIIPER and usbip-win2." + $script:AppExecutable = Resolve-AppExecutable $AppExecutable + Write-SetupLog "Verification helper: $script:AppExecutable" + + Write-Step "Pinned packages" + $pins = (Invoke-InstallerPolicy @("pins")).Data + Write-Step "Checking usbip-win2" - $requiredUsbipVersion = [Version]"0.9.7.7" - try { - $usbipVersion = Get-UsbipInstalledVersion - } - catch { - Write-SetupLog "usbip-win2 version check failed: $($_.Exception.Message)" Yellow - $usbipVersion = $null + $registered = Get-UsbipRegisteredRelease + $usbipDecision = Invoke-InstallerPolicy @( + "usbip-decision", "--uninstall-version", $registered) + $action = $usbipDecision.Data['action'] + + switch ($action) { + "InstallPinned" { + $installerPath = Join-Path $script:TempDir $pins['usbip.filename'] + Get-VerifiedPinnedFile "usbip" $pins $installerPath $UsbipInstallerFile + + Write-SetupLog "Windows may briefly restart USB hub devices." Yellow + $installer = Start-Process -FilePath $installerPath ` + -ArgumentList "/S" -PassThru -Wait + if ($installer.ExitCode -notin @(0, 1641, 3010)) { + throw "usbip-win2 setup failed with exit code $($installer.ExitCode)." + } + if ($installer.ExitCode -in @(1641, 3010)) { + $script:RebootRecommended = $true + Write-SetupLog "The installer asked for a Windows restart." Yellow + } + } + "AlreadyPinned" { + Write-SetupLog $usbipDecision.Data['summary'] Green + } + "LeaveRecognisedReleaseAlone" { + Write-SetupLog $usbipDecision.Data['summary'] Yellow + } + "RefuseUnrecognisedInstall" { + $script:Refused = $true + Write-SetupLog $usbipDecision.Data['summary'] Red + } + default { + # An action nobody wrote a branch for is not a licence to guess. + throw ( + "The installer policy returned an unrecognised usbip-win2 " + + "action '$action'. Setup stops rather than guessing what it " + + "means.") + } } - if ($usbipVersion -and $usbipVersion -ge $requiredUsbipVersion) { - Write-SetupLog "usbip-win2 is ready: $usbipVersion" Green + + Write-Step "Validating the installed driver packages" + $validation = Invoke-InstallerPolicy @("validate-installed") + if ($validation.ExitCode -eq 0) { + $script:DriverValidated = $true + Write-SetupLog $validation.Data['summary'] Green } else { - $state = if ($usbipVersion) { "old ($usbipVersion)" } else { "missing" } - Write-SetupLog "usbip-win2 is $state; installing $requiredUsbipVersion." Yellow - $usbipUrl = "https://github.com/vadimgrn/usbip-win2/releases/download/v.0.9.7.7/USBip-0.9.7.7-x64.exe" - $usbipInstaller = Join-Path $script:TempDir "USBip-0.9.7.7-x64.exe" - Invoke-Download $usbipUrl $usbipInstaller - Write-SetupLog "Windows may briefly restart USB hub devices." Yellow - $installer = Start-Process -FilePath $usbipInstaller ` - -ArgumentList "/S" -PassThru -Wait - if ($installer.ExitCode -notin @(0, 1641, 3010)) { - throw "usbip-win2 setup failed with exit code $($installer.ExitCode)." - } - if ($installer.ExitCode -in @(1641, 3010)) { - $script:RebootRecommended = $true - } - $usbipVersion = Get-UsbipInstalledVersion - if (-not $usbipVersion) { + Write-SetupLog $validation.Data['summary'] Yellow + if (-not $script:Refused -and $action -eq "InstallPinned") { + # A pair that is not bound yet is the ordinary outcome of installing + # a kernel driver, not evidence of a bad one. $script:RebootRecommended = $true - Write-SetupLog "The driver will finish registering after a Windows restart." Yellow + Write-SetupLog ( + "Restart Windows, then run Install / Repair again so the " + + "installed packages can be validated.") Yellow } } Write-Step "Installing VIIPER" $viiperPath = Join-Path $script:InstallDir "viiper.exe" $candidatePath = Join-Path $script:TempDir "viiper.exe" - Expand-ViiperAsset (Get-ViiperAssetUrl) $candidatePath + Get-VerifiedPinnedFile "viiper" $pins $candidatePath $ViiperBackendFile Install-ViiperAtomically $candidatePath $viiperPath Write-SetupLog "VIIPER installed to $viiperPath" Green - Write-Step "Registering VIIPER" - $registrationSafeToRun = $true - if (-not (Stop-ViiperProcesses "install registration")) { - $registrationSafeToRun = $false - } - - $registration = Start-Process -FilePath $viiperPath ` - -ArgumentList "install" -WindowStyle Hidden -PassThru -Wait - if ($registration.ExitCode -ne 0) { - if (-not $registrationSafeToRun) { - throw "VIIPER registration could not proceed because a VIIPER process could not be closed automatically. " + - "Please close viiper.exe manually, then run Install / Repair again." - } - throw "VIIPER registration failed with exit code $($registration.ExitCode)." - } - - $taskName = "RunVIIPER" - if (Register-ViiperRunTask $viiperPath $taskName) { - Write-SetupLog "Registered hidden logon task '$taskName'." Green + Write-Step "Startup behaviour" + # No autostart entry is created here, by either mechanism. Thrum starts the + # backend when a profile needs it and stops it again on exit, so a logon + # entry would start a backend the application never owns, never stops, and + # whose self-updater is enabled. + $autostartArgs = @("autostart") + if ($RemoveViiperAutostart) { $autostartArgs += "--remove" } + $autostart = Invoke-InstallerPolicy $autostartArgs + if ($autostart.ExitCode -ne 0) { + Write-SetupLog $autostart.Data['summary'] Yellow + } + elseif ($autostart.Data['action'] -eq "OfferRemoval") { + Write-SetupLog $autostart.Data['summary'] Yellow + Write-SetupLog ( + "To remove it now, rerun setup with -RemoveViiperAutostart, or use " + + "Settings -> VIIPER in Thrum.") Yellow } else { - Write-SetupLog "Could not create hidden logon task. Setup will continue; VIIPER can still be started by Thrum when needed." Yellow + Write-SetupLog $autostart.Data['summary'] } Write-Step "Verification" - if (Start-AndVerifyViiper $viiperPath) { + if (Start-AndVerifyViiper $viiperPath $pins) { Write-SetupLog "VIIPER API is ready." Green + # The .previous backup is kept deliberately. Rollback that only exists + # inside the install window is not rollback: the failure this protects + # against is a backend that installs cleanly and then misbehaves. $backupPath = "$viiperPath.previous" if (Test-Path -LiteralPath $backupPath) { - Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue + Write-SetupLog ( + "The previous backend was kept at $backupPath for rollback.") } } elseif ($script:RebootRecommended) { @@ -458,13 +590,29 @@ try { throw "VIIPER installed, but its local API did not start. See $script:LogPath" } - Write-Host "" - $finish = if ($script:RebootRecommended) { - "Setup complete. Restart Windows once before using a virtual controller." - } else { - "Setup complete. VIIPER is ready for Thrum." + if ($script:Refused) { + $script:ExitCode = 1 + Write-SetupLog ( + "Setup finished, but the usbip-win2 packages on this machine are " + + "not ones this build recognises. Virtual controllers stay blocked " + + "until that is resolved; nothing was installed over them.") Red + } + elseif ($script:DriverValidated) { + $script:ExitCode = 0 + Write-SetupLog "Setup complete. VIIPER is ready for Thrum." Green + } + elseif ($script:RebootRecommended) { + $script:ExitCode = 3 + Write-SetupLog ( + "Setup complete. Restart Windows once, then run Install / Repair " + + "again so the driver packages can be validated.") Yellow + } + else { + $script:ExitCode = 1 + Write-SetupLog ( + "Setup finished, but the installed driver packages could not be " + + "validated. Virtual controllers stay blocked until they are.") Red } - Write-SetupLog $finish Green } catch { $script:ExitCode = 1