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