From 5908a883290d7f282e359bf64fee050c17d17695 Mon Sep 17 00:00:00 2001 From: informent Date: Mon, 12 Oct 2026 13:39:07 -0300 Subject: [PATCH] Add unattended workflows and bounded verification --- .github/workflows/ci.yml | 4 ++ .github/workflows/verify-release.yml | 49 ++++++++++++++++++++++++ .gitignore | 2 + App.xaml.cs | 2 +- Checkup.csproj | 2 +- CheckupEngine.cs | 57 ++++++++++++++++++++-------- CommandLine.cs | 19 ++++++++++ README.md | 17 ++++++++- RELEASE-NOTES.md | 10 +++++ SupportBundle.cs | 4 +- tests/CheckupEngineTests.cs | 28 ++++++++++++++ tests/Test-Headless.ps1 | 43 +++++++++++++++++++++ tests/Test-PackagedLaunch.ps1 | 10 +++++ 13 files changed, 225 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/verify-release.yml create mode 100644 CommandLine.cs create mode 100644 RELEASE-NOTES.md create mode 100644 tests/Test-Headless.ps1 create mode 100644 tests/Test-PackagedLaunch.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 053d5af..3e2ba97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,10 @@ jobs: $exe = Join-Path $PWD 'test-output/Checkup.exe' $info = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($exe) if ($info.ProductName -ne 'Checkup' -or $info.FileDescription -ne 'Checkup') { throw 'Executable product branding is incorrect' } + - name: Verify unattended workflow + shell: pwsh + run: | + ./tests/Test-Headless.ps1 -Exe (Join-Path $PWD 'test-output/Checkup.exe') - uses: actions/upload-artifact@v7 with: name: Checkup-win-x64 diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml new file mode 100644 index 0000000..58de94b --- /dev/null +++ b/.github/workflows/verify-release.yml @@ -0,0 +1,49 @@ +name: Verify published release +on: + workflow_dispatch: + inputs: + tag: + description: Published release tag to verify + required: true + type: string +permissions: + contents: read +jobs: + verify: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + - uses: actions/setup-dotnet@v6 + with: { dotnet-version: 10.0.x } + - name: Download published assets and verify their GitHub digests + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + if ($env:RELEASE_TAG -notmatch '^v\d+\.\d+\.\d+$') { throw 'Invalid release tag' } + gh release download $env:RELEASE_TAG --dir verified + if ($LASTEXITCODE -ne 0) { throw 'Release download failed' } + $release = gh release view $env:RELEASE_TAG --json assets | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw 'Release lookup failed' } + foreach ($asset in $release.assets) { + $hash = 'sha256:' + (Get-FileHash -LiteralPath (Join-Path verified $asset.name) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($hash -ne $asset.digest) { throw "Asset hash mismatch: $($asset.name)" } + } + $zip = Join-Path verified ('Checkup-' + $env:RELEASE_TAG + '-win-x64.zip') + Expand-Archive -LiteralPath $zip -DestinationPath verified/portable + - name: Verify the downloaded portable command workflow + shell: pwsh + run: ./tests/Test-Headless.ps1 -Exe (Join-Path $PWD 'verified/portable/Checkup.exe') + - name: Verify the downloaded portable desktop workflow + shell: powershell + run: ./tests/Test-PackagedLaunch.ps1 -Exe "${{ github.workspace }}/verified/portable/Checkup.exe" + - name: Verify the downloaded installer lifecycle + shell: pwsh + env: + RELEASE_TAG: ${{ inputs.tag }} + run: ./tests/Test-Installer.ps1 -Setup (Join-Path $PWD ('verified/Checkup-' + $env:RELEASE_TAG + '-setup.exe')) diff --git a/.gitignore b/.gitignore index 296fdc5..7035b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ tests/obj/ installer-payload/ installer-output/ + +test-headless-output/ diff --git a/App.xaml.cs b/App.xaml.cs index 97cc4c3..c422823 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -5,7 +5,7 @@ public partial class App : System.Windows.Application { public App() { - Startup += (_, _) => new MainWindow().Show(); + Startup += (_, e) => { if (e.Args.Length > 0) { Shutdown(CommandLine.Run(e.Args)); return; } new MainWindow().Show(); }; DispatcherUnhandledException += (_, e) => { ShowStartupError(e.Exception); e.Handled = true; }; AppDomain.CurrentDomain.UnhandledException += (_, e) => ShowStartupError(e.ExceptionObject as Exception ?? new Exception("Unknown startup error.")); } diff --git a/Checkup.csproj b/Checkup.csproj index 52f92f3..a5aeac0 100644 --- a/Checkup.csproj +++ b/Checkup.csproj @@ -1 +1 @@ -WinExenet10.0-windowstruetrueenableenable1.1.1falsefalsefalseCheckupCheckupinforment +WinExenet10.0-windowstruetrueenableenable1.2.0falsefalsefalseCheckupCheckupinforment diff --git a/CheckupEngine.cs b/CheckupEngine.cs index ad6517f..e827af1 100644 --- a/CheckupEngine.cs +++ b/CheckupEngine.cs @@ -7,15 +7,19 @@ public enum FindingLevel { Good, Attention, Critical } public sealed record Finding(string Area, FindingLevel Level, string Title, string Explanation, string Evidence, bool CanRepair = false); public static class CheckupEngine { - public static IReadOnlyList Scan() + public static IReadOnlyList Scan() => Scan(CancellationToken.None); + public static IReadOnlyList Scan(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var findings = new List(); var system = DriveInfo.GetDrives().FirstOrDefault(d => d.IsReady && string.Equals(d.Name, Path.GetPathRoot(Environment.SystemDirectory), StringComparison.OrdinalIgnoreCase)); if (system is not null) { var free = system.AvailableFreeSpace / 1_073_741_824d; findings.Add(free < 10 ? new Finding("Storage", FindingLevel.Attention, "Low system-drive space", "Windows may become unstable when the system drive is nearly full.", $"{free:0.0} GB available", true) : new Finding("Storage", FindingLevel.Good, "System-drive space is healthy", "There is enough free space for normal Windows operation.", $"{free:0.0} GB available")); } - var temp = Path.GetTempPath(); var tempBytes = MeasureTempBytes(temp); + var temp = Path.GetTempPath(); var inventory = MeasureTemp(temp, cancellationToken); var tempBytes = inventory.Bytes; findings.Add(tempBytes > 5_000_000_000 ? new Finding("Storage", FindingLevel.Attention, "Temporary files are growing large", "Temporary files can be reviewed before cleanup. Checkup will never delete them without an explicit preview.", $"{tempBytes / 1_073_741_824d:0.0} GB in the current temp tree", true) : new Finding("Storage", FindingLevel.Good, "Temporary-file footprint is reasonable", "No unusually large temporary-file footprint was detected.", $"{tempBytes / 1_048_576d:0.0} MB measured")); + if (!inventory.Complete) findings.Add(new Finding("Storage", FindingLevel.Attention, "Temporary-file scan has partial coverage", "Linked or unreadable entries were skipped, or the scan budget was reached. The measured size is a lower bound.", $"{inventory.Files} files measured; {inventory.Skipped} entries skipped; budget reached: {inventory.BudgetReached}")); + cancellationToken.ThrowIfCancellationRequested(); findings.Add(NetworkInterface.GetIsNetworkAvailable() ? new Finding("Connectivity", FindingLevel.Good, "Network is available", "Windows reports an active network interface.", "NetworkInterface.GetIsNetworkAvailable() = true") : new Finding("Connectivity", FindingLevel.Attention, "No network is currently available", "This may be intentional if the device is offline.", "No active network interface reported")); - var run = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); var startupCount = run?.GetValueNames().Length ?? 0; + using var run = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); var startupCount = run?.GetValueNames().Length ?? 0; findings.Add(new Finding("Startup", FindingLevel.Good, "Startup entries inventoried", "Checkup found the current-user startup entries without changing them.", $"{startupCount} current-user entries")); var activation = ReadActivationEvidence(); findings.Add(new Finding("Activation", activation.IsActivated ? FindingLevel.Good : FindingLevel.Attention, activation.IsActivated ? "Windows reports activation is valid" : "Windows activation needs review", activation.IsActivated ? "The official Windows licensing script reports an active license." : "Checkup cannot activate Windows, but you can review the official status and troubleshoot it from Windows Settings.", activation.Evidence)); @@ -39,21 +43,42 @@ private static (bool IsActivated, string Evidence) ReadActivationEvidence() } catch (Exception ex) { return (false, $"Activation status unavailable: {ex.Message}"); } } - private static long SafeLength(string path) { try { return new FileInfo(path).Length; } catch { return 0; } } - private static long MeasureTempBytes(string root) + public sealed record TempInventory(long Bytes, int Files, int Skipped, bool BudgetReached) { - if (!Directory.Exists(root)) return 0; - long total = 0; - var pending = new Stack(); - pending.Push(root); - while (pending.Count > 0) + public bool Complete => Skipped == 0 && !BudgetReached; + } + + public static TempInventory MeasureTemp(string root, CancellationToken cancellationToken = default, int maxEntries = 200_000, TimeSpan? budget = null) + { + if (maxEntries < 1 || budget is { } duration && duration <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(maxEntries)); + cancellationToken.ThrowIfCancellationRequested(); + var timer = Stopwatch.StartNew(); var timeout = budget ?? TimeSpan.FromSeconds(15); + long total = 0; var files = 0; var skipped = 0; var visited = 0; + var pending = new Stack(); pending.Push(Path.GetFullPath(root)); + while (pending.TryPop(out var directory)) { - var directory = pending.Pop(); - try { foreach (var file in Directory.EnumerateFiles(directory)) total = SaturatingAdd(total, SafeLength(file)); } catch { } - try { foreach (var child in Directory.EnumerateDirectories(directory)) pending.Push(child); } catch { } + cancellationToken.ThrowIfCancellationRequested(); + if (timer.Elapsed >= timeout) return new(total, files, skipped, true); + try + { + if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) { skipped++; continue; } + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (++visited > maxEntries || timer.Elapsed >= timeout) return new(total, files, skipped, true); + try + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) { skipped++; continue; } + if ((attributes & FileAttributes.Directory) != 0) pending.Push(entry); + else { var length = new FileInfo(entry).Length; total = length > long.MaxValue - total ? long.MaxValue : total + length; files++; } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { skipped++; } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { skipped++; } } - return total; + return new(total, files, skipped, false); } - internal static long MeasureTempBytesForTesting(string root) => MeasureTempBytes(root); - private static long SaturatingAdd(long total, long value) => value > long.MaxValue - total ? long.MaxValue : total + value; + internal static long MeasureTempBytesForTesting(string root) => MeasureTemp(root).Bytes; } diff --git a/CommandLine.cs b/CommandLine.cs new file mode 100644 index 0000000..e7585be --- /dev/null +++ b/CommandLine.cs @@ -0,0 +1,19 @@ +using System.IO; +namespace Checkup; + +public static class CommandLine +{ + public static int Run(string[] args) + { + if (args.Length != 3 || args[0] != "--scan" || args[1] != "--output" || string.IsNullOrWhiteSpace(args[2]) || !args[2].EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) return 2; + try + { + if (File.Exists(args[2])) return 2; + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + var findings = CheckupEngine.Scan(timeout.Token); + SupportBundle.Export(args[2], findings, overwrite: false); + return 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OperationCanceledException or System.Security.SecurityException) { return 1; } + } +} diff --git a/README.md b/README.md index 65860d4..20a004d 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,29 @@ Checkup is not affiliated with Microsoft or Windows. Checkup replaces OpenFix. New builds use Checkup.exe and Checkup.csproj. Existing releases remain available under their original names. +## Unattended support bundles + +Version 1.2.0 can generate a private support ZIP without opening a window: + +```powershell +$p = Start-Process .\Checkup.exe -WindowStyle Hidden -ArgumentList '--scan --output "C:\Reports\checkup.zip"' -Wait -PassThru +$p.ExitCode +``` + +Use a new ZIP filename. Existing reports are never replaced by this command. Exit 0 means the report was written, 2 means invalid arguments or an existing output, and 1 means the operation failed. Findings inside the report determine health; a successful export does not mean the machine has no issues. + +Temporary-file inventory now skips links and junctions, supports cancellation, and stops after 200,000 entries or 15 seconds of enumeration. Partial coverage creates an attention finding with measured-file and skipped-entry counts, so a lower-bound size is not mistaken for a complete scan. Filesystem calls already in progress can outlast the enumeration budget. Bundles remain local and should be reviewed before sharing. + ## Automatic setup Windows setup packages install Checkup for your current account, bundle the .NET runtime, create a Start menu shortcut and register an uninstaller. A desktop shortcut is optional. No administrator password or manual file copying is required. Existing settings and saved data stay in their current locations. The portable ZIP remains available. -Download `Checkup-v1.1.1-setup.exe` from Releases and run it. +Download `Checkup-v1.2.0-setup.exe` from Releases and run it. For unattended deployment: ```powershell -Start-Process .\Checkup-v1.1.1-setup.exe -ArgumentList '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' -Wait -PassThru +Start-Process .\Checkup-v1.2.0-setup.exe -ArgumentList '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' -Wait -PassThru ``` Build from source with the [open-source Inno Setup compiler](https://github.com/jrsoftware/issrc) installed: diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md new file mode 100644 index 0000000..ed25c1f --- /dev/null +++ b/RELEASE-NOTES.md @@ -0,0 +1,10 @@ +# Checkup 1.2.0 + +Checkup can now generate a local support bundle from a scheduled task without opening the desktop. Temporary-file inventory skips links, reports partial coverage, and has entry and time budgets. Existing command output is preserved. + +## Verification + +Engine regressions cover partial counts, missing roots, cancellation, cyclic junctions and exclusive exports. The self-contained EXE passed unattended report generation, existing-output protection and invalid argument checks locally. + +Automatic setup and portable builds include the .NET runtime. Installers are unsigned. ZIP checksums verify downloaded bytes; they do not authenticate an unknown publisher. + diff --git a/SupportBundle.cs b/SupportBundle.cs index f21fc47..9851af3 100644 --- a/SupportBundle.cs +++ b/SupportBundle.cs @@ -32,7 +32,7 @@ public static HealthReport CreateReport(IReadOnlyList findings, DateTim return new HealthReport(now ?? DateTimeOffset.UtcNow, score, grade, summary, safeFindings); } - public static BundleResult Export(string destination, IReadOnlyList findings) + public static BundleResult Export(string destination, IReadOnlyList findings, bool overwrite = true) { if (string.IsNullOrWhiteSpace(destination)) throw new ArgumentException("A destination is required.", nameof(destination)); var report = CreateReport(findings); @@ -61,7 +61,7 @@ public static BundleResult Export(string destination, IReadOnlyList fin using var stream = entry.Open(); stream.Write(file.Value); } - File.Move(temporary, fullPath, true); + File.Move(temporary, fullPath, overwrite); } finally { if (File.Exists(temporary)) File.Delete(temporary); } var bytes = File.ReadAllBytes(fullPath); diff --git a/tests/CheckupEngineTests.cs b/tests/CheckupEngineTests.cs index 6fe6adc..9fb5967 100644 --- a/tests/CheckupEngineTests.cs +++ b/tests/CheckupEngineTests.cs @@ -50,3 +50,31 @@ } finally { if (File.Exists(output)) File.Delete(output); } Console.WriteLine("PASS: support bundle scoring, redaction, contents, and hashes verified"); + +var inventoryRoot = Path.Combine(Path.GetTempPath(), "checkup-budget-" + Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(inventoryRoot); +try +{ + for (var i = 0; i < 4; i++) File.WriteAllText(Path.Combine(inventoryRoot, i + ".txt"), "fixture"); + var limited = CheckupEngine.MeasureTemp(inventoryRoot, maxEntries: 2); + if (limited.Complete || !limited.BudgetReached || limited.Files != 2 || limited.Bytes != 14) throw new Exception("Inventory budget did not preserve partial counts."); + var complete = CheckupEngine.MeasureTemp(inventoryRoot); + if (!complete.Complete || complete.Files != 4 || complete.Bytes != 28) throw new Exception("Complete inventory is inaccurate."); + var missing = CheckupEngine.MeasureTemp(Path.Combine(inventoryRoot, "missing")); + if (missing.Complete || missing.Skipped != 1) throw new Exception("Missing inventory root appeared complete."); + using var canceled = new CancellationTokenSource(); canceled.Cancel(); + try { CheckupEngine.MeasureTemp(inventoryRoot, canceled.Token); throw new Exception("Canceled inventory continued."); } catch (OperationCanceledException) { } + var bundlePath = Path.Combine(inventoryRoot, "preserved.zip"); File.WriteAllText(bundlePath, "preserve"); + try { SupportBundle.Export(bundlePath, sample, overwrite: false); throw new Exception("Existing bundle overwritten."); } catch (IOException) { } + if (File.ReadAllText(bundlePath) != "preserve" || Directory.GetFiles(inventoryRoot, "*.tmp-*").Length != 0) throw new Exception("Exclusive export failed cleanup."); + if (CommandLine.Run(new[] { "--scan", "--output", bundlePath }) != 2 || CommandLine.Run(new[] { "--bad" }) != 2) throw new Exception("CLI accepted invalid or existing output."); + var junction = Path.Combine(inventoryRoot, "loop"); + var start = new System.Diagnostics.ProcessStartInfo("cmd.exe") { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var argument in new[] { "/c", "mklink", "/J", junction, inventoryRoot }) start.ArgumentList.Add(argument); + using var process = System.Diagnostics.Process.Start(start)!; process.WaitForExit(); + if (process.ExitCode != 0) throw new Exception("Junction fixture creation failed."); + try { var linked = CheckupEngine.MeasureTemp(inventoryRoot); if (linked.Skipped != 1 || linked.Complete) throw new Exception("Linked inventory entry was followed."); } + finally { Directory.Delete(junction); } +} +finally { Directory.Delete(inventoryRoot, true); } +Console.WriteLine("PASS: inventory budgets, cancellation, missing roots, cyclic links, exclusive bundles and CLI validation"); diff --git a/tests/Test-Headless.ps1 b/tests/Test-Headless.ps1 new file mode 100644 index 0000000..93d3841 --- /dev/null +++ b/tests/Test-Headless.ps1 @@ -0,0 +1,43 @@ +param([Parameter(Mandatory=$true)][string]$Exe) +$ErrorActionPreference = 'Stop' +$root = Join-Path ([IO.Path]::GetTempPath()) ('Checkup-headless-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $root | Out-Null +function Invoke-Headless([string]$Arguments, [int]$Expected) { + $process = Start-Process -FilePath $Exe -ArgumentList $Arguments -WindowStyle Hidden -PassThru + try { + $deadline = [DateTime]::UtcNow.AddSeconds(150) + while (!$process.HasExited) { + $process.Refresh() + if ($process.MainWindowHandle -ne 0) { throw 'Unattended command opened a desktop window' } + if ([DateTime]::UtcNow -ge $deadline) { throw 'Unattended command timed out' } + Start-Sleep -Milliseconds 100 + } + $process.WaitForExit() + if ($process.ExitCode -ne $Expected) { throw "Expected exit $Expected, received $($process.ExitCode)" } + } + finally { if (!$process.HasExited) { $process.Kill(); $process.WaitForExit() }; $process.Dispose() } +} +try { + $report = Join-Path $root 'support.zip' + $arguments = '--scan --output "' + $report + '"' + Invoke-Headless $arguments 0 + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($report) + try { + if ($archive.Entries.Count -ne 4 -or !$archive.GetEntry('report.json') -or !$archive.GetEntry('manifest-sha256.txt')) { throw 'Support bundle is incomplete' } + $reader = [IO.StreamReader]::new($archive.GetEntry('report.json').Open()) + try { $json = $reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() } + if ($json.Findings.Count -lt 4) { throw 'Health findings missing' } + } finally { $archive.Dispose() } + $before = (Get-FileHash -LiteralPath $report -Algorithm SHA256).Hash + Invoke-Headless $arguments 2 + if ((Get-FileHash -LiteralPath $report -Algorithm SHA256).Hash -ne $before) { throw 'Existing report changed' } + Invoke-Headless '--invalid' 2 + Write-Output 'PASS: packaged unattended workflow, report content, existing-output protection, argument exits and no desktop window' +} +finally { + $resolved = [IO.Path]::GetFullPath($root) + $temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (!$resolved.StartsWith($temp, [StringComparison]::OrdinalIgnoreCase) -or !([IO.Path]::GetFileName($resolved)).StartsWith('Checkup-headless-')) { throw 'Unsafe cleanup path' } + Remove-Item -LiteralPath $resolved -Recurse -Force +} diff --git a/tests/Test-PackagedLaunch.ps1 b/tests/Test-PackagedLaunch.ps1 new file mode 100644 index 0000000..92b5d32 --- /dev/null +++ b/tests/Test-PackagedLaunch.ps1 @@ -0,0 +1,10 @@ +param([Parameter(Mandatory=$true)][string]$Exe) +$ErrorActionPreference = 'Stop' +$process = Start-Process -FilePath $Exe -WindowStyle Hidden -PassThru +try { + Start-Sleep -Seconds 3 + $process.Refresh() + if ($process.HasExited -or $process.MainWindowHandle -eq 0) { throw 'Downloaded Checkup did not create its desktop window' } + Write-Output 'PASS: exact downloaded Checkup desktop executable starts successfully' +} +finally { if (!$process.HasExited) { $process.Kill(); $process.WaitForExit() }; $process.Dispose() }