From 51a1e3e9d09da6cc09dcc4873e1a5c3da41cf5fa Mon Sep 17 00:00:00 2001 From: OneDeadMachine Date: Fri, 14 Aug 2026 08:48:57 +0300 Subject: [PATCH] fix: harden PowerShell execution and fixer results --- .github/workflows/ci.yml | 30 ++ LICENSE | 21 ++ README.md | 3 + W-Fix.sln | 6 + docs/architecture.md | 40 +++ src/W-Fix.Core/Fixers/DriverFixer.cs | 6 + src/W-Fix.Core/Fixers/Error02Fixer.cs | 17 +- src/W-Fix.Core/Fixers/Error4005Fixer.cs | 16 +- src/W-Fix.Core/Fixers/Error7eFixer.cs | 22 +- src/W-Fix.Core/Fixers/Error8Fixer.cs | 33 +- src/W-Fix.Core/Fixers/IppFixer.cs | 14 +- .../Models/PowerShellExecutionResult.cs | 40 +++ src/W-Fix.Core/Services/PowerShellEngine.cs | 325 +++++++++++------- src/W-Fix.Core/W-Fix.Core.csproj | 5 +- .../W-Fix.Core.Tests/PowerShellEngineTests.cs | 63 ++++ .../PowerShellExecutionResultTests.cs | 33 ++ tests/W-Fix.Core.Tests/Usings.cs | 1 + .../W-Fix.Core.Tests/W-Fix.Core.Tests.csproj | 24 ++ 18 files changed, 535 insertions(+), 164 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 docs/architecture.md create mode 100644 src/W-Fix.Core/Models/PowerShellExecutionResult.cs create mode 100644 tests/W-Fix.Core.Tests/PowerShellEngineTests.cs create mode 100644 tests/W-Fix.Core.Tests/PowerShellExecutionResultTests.cs create mode 100644 tests/W-Fix.Core.Tests/Usings.cs create mode 100644 tests/W-Fix.Core.Tests/W-Fix.Core.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c5203e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-and-test: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore W-Fix.sln + + - name: Build + run: dotnet build W-Fix.sln --configuration Release --no-restore + + - name: Test + run: dotnet test W-Fix.sln --configuration Release --no-build + + - name: NuGet vulnerability report + run: dotnet list W-Fix.sln package --vulnerable --include-transitive diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..149028f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OneDeadMachine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 982c2ad..307bcef 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,9 @@ W-Fix/ **Стек:** WPF · .NET 8 · ModernWpfUI · CommunityToolkit.Mvvm · PowerShell SDK · Serilog +Подробности внутренних границ, выполнения PowerShell и правил для фиксеров: +[docs/architecture.md](docs/architecture.md). + --- ## 📋 Системные требования diff --git a/W-Fix.sln b/W-Fix.sln index 6f455e7..84aa58c 100644 --- a/W-Fix.sln +++ b/W-Fix.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "W-Fix.Core", "src\W-Fix.Cor EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "W-Fix.App", "src\W-Fix.App\W-Fix.App.csproj", "{A1B2C3D4-0002-0002-0002-000000000002}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "W-Fix.Core.Tests", "tests\W-Fix.Core.Tests\W-Fix.Core.Tests.csproj", "{A1B2C3D4-0003-0003-0003-000000000003}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,5 +23,9 @@ Global {A1B2C3D4-0002-0002-0002-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-0002-0002-0002-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-0002-0002-0002-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-0003-0003-0003-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-0003-0003-0003-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-0003-0003-0003-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-0003-0003-0003-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..faaa7f2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,40 @@ +# Архитектура W-Fix + +## Границы компонентов + +- `W-Fix.App` — WPF-интерфейс, выбор цели и сценария, отображение прогресса. +- `W-Fix.Core.Models` — данные принтеров, журнал и типизированные результаты операций. +- `W-Fix.Core.Services` — обнаружение принтеров, Active Directory и выполнение PowerShell. +- `W-Fix.Core.Fixers` — прикладные сценарии диагностики и исправления. + +UI не должен самостоятельно определять успешность системной команды. Источником истины является +`PowerShellExecutionResult`, а итог многошагового фиксера вычисляется из результатов всех выполненных шагов. + +## Выполнение PowerShell + +`PowerShellEngine` поддерживает два режима: + +1. Встроенный runspace PowerShell SDK — в том числе для `Invoke-Command` на удалённой машине. +2. Внешний Windows PowerShell 5.1 — для Windows-модулей, несовместимых со встроенным Core-runspace. + +Оба режима имеют общий десятиминутный таймаут по умолчанию. Пользовательская отмена пробрасывается как +`OperationCanceledException`; таймаут возвращается как неуспешный `PowerShellExecutionResult` с +`TimedOut = true`. Внешний процесс при этом завершается вместе с дочерними процессами. + +Скрипты используют префиксы `[OK]`, `[INFO]`, `[WARN]` и `[ERROR]`. Даже если PowerShell-скрипт перехватил +исключение, строка `[ERROR]` делает результат неуспешным. Служебный progress-CLIXML Windows PowerShell 5.1 +в `stderr` не считается ошибкой. + +## Правила для фиксеров + +- Не сообщать `Success`, если обязательный шаг завершился неуспешно. +- Не скрывать ошибку обязательного шага за успешным перезапуском Spooler. +- Ограничивать удаление файлов только ресурсами подсистемы печати. +- Для потенциально опасных изменений реестра сохранять исходное состояние перед будущей реализацией rollback. +- Любой новый сценарий должен иметь тестируемую логику определения результата. + +## Следующий архитектурный этап + +Вынести создание сервисов из `MainWindowViewModel` в composition root и ввести интерфейсы исполнителя, +обнаружения принтеров и диалогов. Это позволит тестировать фиксеры без реального изменения Windows и добавить +режим предварительного просмотра/отката системных настроек. diff --git a/src/W-Fix.Core/Fixers/DriverFixer.cs b/src/W-Fix.Core/Fixers/DriverFixer.cs index 5578a3b..ed370d4 100644 --- a/src/W-Fix.Core/Fixers/DriverFixer.cs +++ b/src/W-Fix.Core/Fixers/DriverFixer.cs @@ -81,9 +81,14 @@ exit 1 # Шаг 1: Установить драйвер через pnputil Write-Output ""[INFO] Добавляем INF в хранилище драйверов через pnputil..."" $pnpResult = pnputil.exe /add-driver ""$infPath"" /install 2>&1 + $pnpExitCode = $LASTEXITCODE foreach ($line in $pnpResult) { Write-Output ""[INFO] pnputil: $line"" } + if ($pnpExitCode -ne 0) { + Write-Output ""[ERROR] pnputil завершился с кодом $pnpExitCode"" + exit $pnpExitCode + } # Шаг 2: Если есть конкретный принтер — попытаемся обновить его драйвер if ($printerName -ne '' -and $driverName -ne 'Unknown') { @@ -249,6 +254,7 @@ exit 1 } } Write-Output ""[INFO] Установите драйвер вручную через INF-файл (режим INF)"" + exit 1 } "; diff --git a/src/W-Fix.Core/Fixers/Error02Fixer.cs b/src/W-Fix.Core/Fixers/Error02Fixer.cs index 57927a7..b0a8327 100644 --- a/src/W-Fix.Core/Fixers/Error02Fixer.cs +++ b/src/W-Fix.Core/Fixers/Error02Fixer.cs @@ -86,7 +86,8 @@ public override async Task ApplyAsync( """; using var engine = new PowerShellEngine(remoteMachine); - var (_, out1, _) = await engine.RunAsync(step1, ct: ct); + var result1 = await engine.RunAsync(step1, ct: ct); + var out1 = result1.Output; ReportOutput(out1, Report); // ── Шаг 2: Очистка Print Environments в реестре ───────────────────────── @@ -114,7 +115,8 @@ public override async Task ApplyAsync( } """; - var (_, out2, _) = await engine.RunAsync(step2, ct: ct); + var result2 = await engine.RunAsync(step2, ct: ct); + var out2 = result2.Output; ReportOutput(out2, Report); // ── Шаг 3: PendingFileRenameOperations ────────────────────────────────── @@ -151,7 +153,8 @@ public override async Task ApplyAsync( } """; - var (_, out3, _) = await engine.RunAsync(step3, ct: ct); + var result3 = await engine.RunAsync(step3, ct: ct); + var out3 = result3.Output; ReportOutput(out3, Report); // ── Шаг 4: Point and Print + RPC ──────────────────────────────────────── @@ -176,7 +179,8 @@ public override async Task ApplyAsync( } """; - var (_, out4, _) = await engine.RunAsync(step4, ct: ct); + var result4 = await engine.RunAsync(step4, ct: ct); + var out4 = result4.Output; ReportOutput(out4, Report); // ── Шаг 5: Перезапуск Spooler ─────────────────────────────────────────── @@ -187,7 +191,10 @@ public override async Task ApplyAsync( Report(Info("Рекомендация: перезагрузите компьютер, затем повторите подключение к принтеру.")); - return spoolerResult.Status == FixStatus.Success + var allStepsSucceeded = new[] { result1, result2, result3, result4 } + .All(result => result.Success); + + return spoolerResult.Status == FixStatus.Success && allStepsSucceeded ? FixResult.Ok("Фикс 0x00000002 выполнен", steps) : FixResult.Warn("Фикс 0x00000002 частично выполнен", steps); } diff --git a/src/W-Fix.Core/Fixers/Error4005Fixer.cs b/src/W-Fix.Core/Fixers/Error4005Fixer.cs index 8396652..34fd550 100644 --- a/src/W-Fix.Core/Fixers/Error4005Fixer.cs +++ b/src/W-Fix.Core/Fixers/Error4005Fixer.cs @@ -28,6 +28,7 @@ public override async Task ApplyAsync( IProgress? progress, CancellationToken ct) { var steps = new List(); + var executionResults = new List(); void Report(LogEntry e) { steps.Add(e); progress?.Report(e); } Report(Info("Диагностика и исправление ошибки 0x00004005...")); @@ -53,8 +54,14 @@ public override async Task ApplyAsync( """; using var engine = remoteMachine != null ? new PowerShellEngine(remoteMachine) : null; - async Task<(bool, IReadOnlyList, string?)> RunPS(string script) => - engine != null ? await engine.RunAsync(script, ct: ct) : await PowerShellEngine.RunExternalAsync(script, ct); + async Task RunPS(string script) + { + var result = engine != null + ? await engine.RunAsync(script, ct: ct) + : await PowerShellEngine.RunExternalAsync(script, ct); + executionResults.Add(result); + return result; + } var (_, out1, _) = await RunPS(step1); ReportOutput(out1, Report); @@ -187,7 +194,10 @@ public override async Task ApplyAsync( Report(Info("═══════════════════════════════════════════════════")); Report(Info("Рекомендация: перезагрузите компьютер для применения всех изменений реестра.")); - return spoolerResult.Status == FixStatus.Success + var allStepsSucceeded = executionResults.All(result => result.Success) && + steps.All(step => step.Level != Models.LogLevel.Error); + + return spoolerResult.Status == FixStatus.Success && allStepsSucceeded ? FixResult.Ok("Фикс 0x00004005 выполнен. Рекомендуется перезагрузка.", steps) : FixResult.Warn("Фикс 0x00004005 частично выполнен — проверьте логи", steps); } diff --git a/src/W-Fix.Core/Fixers/Error7eFixer.cs b/src/W-Fix.Core/Fixers/Error7eFixer.cs index dff4726..cc8d282 100644 --- a/src/W-Fix.Core/Fixers/Error7eFixer.cs +++ b/src/W-Fix.Core/Fixers/Error7eFixer.cs @@ -31,6 +31,7 @@ public override async Task ApplyAsync( IProgress? progress, CancellationToken ct) { var steps = new List(); + var executionResults = new List(); void Report(LogEntry e) { steps.Add(e); progress?.Report(e); } Report(Info("Диагностика и исправление ошибки 0x0000007e...")); @@ -72,7 +73,9 @@ public override async Task ApplyAsync( """; using var engine = new PowerShellEngine(remoteMachine); - var (_, out1, _) = await engine.RunAsync(step1, ct: ct); + var result1 = await engine.RunAsync(step1, ct: ct); + executionResults.Add(result1); + var out1 = result1.Output; ReportOutput(out1, Report); // ── Шаг 2: Удаление BIDI-ключа реестра ────────────────────────────────── @@ -104,7 +107,9 @@ reg export ""HKLM\SYSTEM\CurrentControlSet\Control\Print\Printers\$printerName\C } "; - var (_, out2, _) = await engine.RunAsync(step2, ct: ct); + var result2 = await engine.RunAsync(step2, ct: ct); + executionResults.Add(result2); + var out2 = result2.Output; ReportOutput(out2, Report); } else @@ -146,7 +151,9 @@ reg export ""HKLM\SYSTEM\CurrentControlSet\Control\Print\Printers\$printerName\C } """; - var (_, out3, _) = await engine.RunAsync(step3, ct: ct); + var result3 = await engine.RunAsync(step3, ct: ct); + executionResults.Add(result3); + var out3 = result3.Output; ReportOutput(out3, Report); // ── Шаг 4: RPC + Spooler ─────────────────────────────────────────────── @@ -165,12 +172,17 @@ reg export ""HKLM\SYSTEM\CurrentControlSet\Control\Print\Printers\$printerName\C Write-Output "[OK] Spooler: $status" """; - var (success, out4, error) = await engine.RunAsync(step4, ct: ct); + var result4 = await engine.RunAsync(step4, ct: ct); + executionResults.Add(result4); + var out4 = result4.Output; ReportOutput(out4, Report); Report(Info("Рекомендация: попробуйте переподключить принтер.")); - return success + var allStepsSucceeded = executionResults.All(result => result.Success); + var error = executionResults.FirstOrDefault(result => !result.Success)?.Error; + + return allStepsSucceeded ? FixResult.Ok("Фикс 0x0000007e выполнен — mscms.dll на месте, BIDI очищен", steps) : FixResult.Warn($"Фикс 0x0000007e частично: {error}", steps); } diff --git a/src/W-Fix.Core/Fixers/Error8Fixer.cs b/src/W-Fix.Core/Fixers/Error8Fixer.cs index b5439c6..f54e0b4 100644 --- a/src/W-Fix.Core/Fixers/Error8Fixer.cs +++ b/src/W-Fix.Core/Fixers/Error8Fixer.cs @@ -52,7 +52,8 @@ public override async Task ApplyAsync(PrinterInfo? printer, string? r """; using var engine = new PowerShellEngine(remoteMachine); - var (_, diagOutput, _) = await engine.RunAsync(diagScript, ct: ct); + var diagnosticResult = await engine.RunAsync(diagScript, ct: ct); + var diagOutput = diagnosticResult.Output; foreach (var line in diagOutput) { var level = line.StartsWith("[OK]") ? Models.LogLevel.Success @@ -68,34 +69,8 @@ public override async Task ApplyAsync(PrinterInfo? printer, string? r var spoolerResult = await spoolerFixer.ApplyAsync(printer, remoteMachine, progress, ct); steps.AddRange(spoolerResult.Steps); - // Дополнительно: очистка временных файлов Windows - var cleanupScript = """ - Write-Output "[INFO] Очистка временных файлов Windows (помогает при нехватке места)..." - $tempPaths = @($env:TEMP, $env:TMP, "$env:SystemRoot\Temp") - $totalDeleted = 0 - foreach ($p in $tempPaths) { - if (Test-Path $p) { - $items = Get-ChildItem -Path $p -Recurse -ErrorAction SilentlyContinue - $items | Where-Object { -not $_.PSIsContainer } | - Remove-Item -Force -ErrorAction SilentlyContinue - $totalDeleted++ - } - } - Write-Output "[OK] Очистка временных папок завершена" - """; - - var (cleanSuccess, cleanOutput, cleanError) = await engine.RunAsync(cleanupScript, ct: ct); - foreach (var line in cleanOutput) - { - var level = line.StartsWith("[OK]") ? Models.LogLevel.Success - : line.StartsWith("[WARN]") ? Models.LogLevel.Warning - : line.StartsWith("[ERROR]") ? Models.LogLevel.Error - : Models.LogLevel.Info; - Report(new LogEntry(level, line)); - } - - return spoolerResult.Status == FixStatus.Success - ? FixResult.Ok("Фикс 0x00000008 выполнен: очередь очищена, временные файлы удалены", steps) + return spoolerResult.Status == FixStatus.Success && diagnosticResult.Success + ? FixResult.Ok("Фикс 0x00000008 выполнен: очередь очищена, Spooler перезапущен", steps) : FixResult.Warn("Фикс 0x00000008 частично выполнен — проверьте логи", steps); } } diff --git a/src/W-Fix.Core/Fixers/IppFixer.cs b/src/W-Fix.Core/Fixers/IppFixer.cs index 1ac45ec..b613b04 100644 --- a/src/W-Fix.Core/Fixers/IppFixer.cs +++ b/src/W-Fix.Core/Fixers/IppFixer.cs @@ -36,6 +36,7 @@ public override async Task ApplyAsync( IProgress? progress, CancellationToken ct) { var steps = new List(); + var executionResults = new List(); void Report(LogEntry e) { steps.Add(e); progress?.Report(e); } Report(Info("Диагностика и исправление сбоя IPP...")); @@ -79,8 +80,14 @@ public override async Task ApplyAsync( """; using var engine = remoteMachine != null ? new PowerShellEngine(remoteMachine) : null; - async Task<(bool, IReadOnlyList, string?)> RunPS(string script) => - engine != null ? await engine.RunAsync(script, ct: ct) : await PowerShellEngine.RunExternalAsync(script, ct); + async Task RunPS(string script) + { + var result = engine != null + ? await engine.RunAsync(script, ct: ct) + : await PowerShellEngine.RunExternalAsync(script, ct); + executionResults.Add(result); + return result; + } var (_, out1, _) = await RunPS(step1); ReportOutput(out1, Report); @@ -262,7 +269,8 @@ public override async Task ApplyAsync( Report(Info("IPP требует включённый компонент Windows и MS IPP Class Driver.")); Report(Info("При сохранении проблем — перезагрузите ПК.")); - var allOk = steps.All(s => s.Level != Models.LogLevel.Error); + var allOk = executionResults.All(result => result.Success) && + steps.All(s => s.Level != Models.LogLevel.Error); return allOk ? FixResult.Ok("Фикс IPP выполнен", steps) : FixResult.Warn("Фикс IPP частично выполнен — проверьте логи", steps); diff --git a/src/W-Fix.Core/Models/PowerShellExecutionResult.cs b/src/W-Fix.Core/Models/PowerShellExecutionResult.cs new file mode 100644 index 0000000..a983296 --- /dev/null +++ b/src/W-Fix.Core/Models/PowerShellExecutionResult.cs @@ -0,0 +1,40 @@ +namespace WFix.Core.Models; + +/// +/// Результат выполнения PowerShell-команды. +/// Дополнительные свойства позволяют отличить ошибку команды от таймаута процесса. +/// +public sealed record PowerShellExecutionResult( + bool Success, + IReadOnlyList Output, + string? Error) +{ + public int? ExitCode { get; init; } + public bool TimedOut { get; init; } + + /// + /// PowerShell-скрипты W-Fix используют префиксы для машинно-читаемого результата. + /// Строка [ERROR] должна влиять на итог операции, даже если скрипт перехватил исключение. + /// + public static bool IsErrorLine(string line) => + line.StartsWith("[ERROR]", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("[EXCEPTION]", StringComparison.OrdinalIgnoreCase); + + public static PowerShellExecutionResult Create( + IEnumerable output, + string? error = null, + int? exitCode = null, + bool timedOut = false) + { + var lines = output.ToList(); + var reportedError = lines.FirstOrDefault(IsErrorLine); + var effectiveError = error ?? reportedError; + var success = !timedOut && (exitCode is null || exitCode == 0) && effectiveError is null; + + return new PowerShellExecutionResult(success, lines, effectiveError) + { + ExitCode = exitCode, + TimedOut = timedOut + }; + } +} diff --git a/src/W-Fix.Core/Services/PowerShellEngine.cs b/src/W-Fix.Core/Services/PowerShellEngine.cs index 38d695c..8d0f7b8 100644 --- a/src/W-Fix.Core/Services/PowerShellEngine.cs +++ b/src/W-Fix.Core/Services/PowerShellEngine.cs @@ -1,17 +1,19 @@ -using System.Collections.ObjectModel; using System.Management.Automation; using System.Management.Automation.Runspaces; using System.Text; +using WFix.Core.Models; namespace WFix.Core.Services; /// /// Потокобезопасный движок PowerShell. /// Каждый вызов запускается в собственном Runspace с политикой Bypass (на уровне процесса). -/// Поддерживает live-стриминг вывода через IAsyncEnumerable. +/// Корректно останавливает pipeline/процесс при отмене и ограничивает время выполнения. /// public class PowerShellEngine : IDisposable { + private static readonly TimeSpan DefaultExecutionTimeout = TimeSpan.FromMinutes(10); + private readonly string? _remoteComputer; private readonly string? _username; private readonly string? _password; @@ -27,78 +29,116 @@ public PowerShellEngine(string? remoteComputer = null, string? username = null, /// /// Выполняет скрипт и возвращает весь вывод строками. /// - public async Task<(bool Success, IReadOnlyList Output, string? Error)> RunAsync( + public async Task RunAsync( string script, Dictionary? parameters = null, - CancellationToken ct = default) + CancellationToken ct = default, + TimeSpan? timeout = null) { - var lines = new List(); - string? errorMsg = null; - bool success = true; + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(script); - await Task.Run(() => - { - try - { - using var runspace = BuildRunspace(); - runspace.Open(); - using var ps = PowerShell.Create(); - ps.Runspace = runspace; + var executionTimeout = ValidateTimeout(timeout); + using var timeoutCts = new CancellationTokenSource(executionTimeout); + using var executionCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); - if (!string.IsNullOrEmpty(_remoteComputer)) - { - // Оборачиваем в Invoke-Command для удалённого выполнения - var sb = new StringBuilder(); - sb.Append("Invoke-Command -ComputerName '"); - sb.Append(_remoteComputer.Replace("'", "''")); - sb.Append("' -ScriptBlock { "); - sb.Append(script); - sb.Append(" }"); - if (!string.IsNullOrEmpty(_username)) - { - sb.Append(" -Credential (New-Object System.Management.Automation.PSCredential('"); - sb.Append(_username.Replace("'", "''")); - sb.Append("', (ConvertTo-SecureString '"); - sb.Append((_password ?? "").Replace("'", "''")); - sb.Append("' -AsPlainText -Force)))"); - } - ps.AddScript(sb.ToString()); - } - else - { - ps.AddScript(script); - } + try + { + return await Task.Run( + () => RunInProcess(script, parameters, executionCts.Token), + CancellationToken.None); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + var message = $"PowerShell превысил таймаут {executionTimeout}."; + return PowerShellExecutionResult.Create( + [$"[ERROR] {message}"], message, timedOut: true); + } + } - if (parameters != null) - { - foreach (var kv in parameters) - ps.AddParameter(kv.Key, kv.Value); - } + private PowerShellExecutionResult RunInProcess( + string script, + Dictionary? parameters, + CancellationToken ct) + { + var lines = new List(); - var results = ps.Invoke(); - foreach (var r in results) - if (r != null) lines.Add(r.ToString() ?? ""); + try + { + ct.ThrowIfCancellationRequested(); + using var runspace = BuildRunspace(); + runspace.Open(); + using var ps = PowerShell.Create(); + ps.Runspace = runspace; - foreach (var e in ps.Streams.Error) + if (!string.IsNullOrEmpty(_remoteComputer)) + { + var sb = new StringBuilder(); + sb.Append("Invoke-Command -ComputerName '"); + sb.Append(_remoteComputer.Replace("'", "''")); + sb.Append("' -ScriptBlock { "); + sb.Append(script); + sb.Append(" }"); + if (!string.IsNullOrEmpty(_username)) { - lines.Add($"[ERROR] {e}"); - success = false; - errorMsg ??= e.ToString(); + sb.Append(" -Credential (New-Object System.Management.Automation.PSCredential('"); + sb.Append(_username.Replace("'", "''")); + sb.Append("', (ConvertTo-SecureString '"); + sb.Append((_password ?? "").Replace("'", "''")); + sb.Append("' -AsPlainText -Force)))"); } - foreach (var w in ps.Streams.Warning) - lines.Add($"[WARN] {w.Message}"); - foreach (var v in ps.Streams.Verbose) - lines.Add($"[VERBOSE] {v.Message}"); + ps.AddScript(sb.ToString()); + } + else + { + ps.AddScript(script); } - catch (Exception ex) when (!ct.IsCancellationRequested) + + if (parameters != null) { - success = false; - errorMsg = ex.Message; - lines.Add($"[EXCEPTION] {ex.Message}"); + foreach (var kv in parameters) + ps.AddParameter(kv.Key, kv.Value); } - }, ct); - return (success, lines, errorMsg); + using var cancellationRegistration = ct.Register(() => + { + try { ps.Stop(); } + catch (ObjectDisposedException) { } + catch (InvalidOperationException) { } + }); + + var results = ps.Invoke(); + ct.ThrowIfCancellationRequested(); + + foreach (var result in results) + if (result != null) lines.Add(result.ToString() ?? ""); + + foreach (var error in ps.Streams.Error) + lines.Add($"[ERROR] {error}"); + foreach (var warning in ps.Streams.Warning) + lines.Add($"[WARN] {warning.Message}"); + foreach (var verbose in ps.Streams.Verbose) + lines.Add($"[VERBOSE] {verbose.Message}"); + + return PowerShellExecutionResult.Create(lines); + } + catch (PipelineStoppedException) when (ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lines.Add($"[EXCEPTION] {ex.Message}"); + return PowerShellExecutionResult.Create(lines, ex.Message); + } } private Runspace BuildRunspace() @@ -130,78 +170,129 @@ private Runspace BuildRunspace() /// Используется для cmdlets, требующих модулей Windows (Get-Printer, Get-PrinterDriver, DISM и т.д.), /// которые недоступны во встроенном PowerShell SDK. /// - public static async Task<(bool Success, IReadOnlyList Output, string? Error)> RunExternalAsync( - string script, CancellationToken ct = default) + public static async Task RunExternalAsync( + string script, + CancellationToken ct = default, + TimeSpan? timeout = null) { + ArgumentException.ThrowIfNullOrWhiteSpace(script); + ct.ThrowIfCancellationRequested(); + + var executionTimeout = ValidateTimeout(timeout); var lines = new List(); - string? errorMsg = null; - bool success = true; - await Task.Run(() => + try { - try - { - // Ищем powershell.exe (Windows PowerShell 5.1 — есть на всех Windows 10/11) - var psExe = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), - "WindowsPowerShell", "v1.0", "powershell.exe"); + var psExe = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); - if (!File.Exists(psExe)) - { - // Fallback на PATH - psExe = "powershell.exe"; - } + if (!File.Exists(psExe)) + psExe = "powershell.exe"; - // Заставляем PS-скрипт отдавать вывод в UTF-8, чтобы мы могли правильно его прочитать с кириллицей - var utf8Script = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;\n" + script; + var utf8Script = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;\n" + script; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(utf8Script)); - // Кодируем скрипт в Base64 для безопасной передачи - var bytes = System.Text.Encoding.Unicode.GetBytes(utf8Script); - var encoded = Convert.ToBase64String(bytes); + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = psExe, + Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {encoded}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = psExe, - Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {encoded}", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - StandardOutputEncoding = System.Text.Encoding.UTF8, - StandardErrorEncoding = System.Text.Encoding.UTF8, - }; - - using var process = System.Diagnostics.Process.Start(psi); - if (process == null) - { - success = false; - errorMsg = "Не удалось запустить powershell.exe"; - return; - } + using var process = System.Diagnostics.Process.Start(psi); + if (process == null) + return PowerShellExecutionResult.Create([], "Не удалось запустить powershell.exe"); - var stdout = process.StandardOutput.ReadToEnd(); - var stderr = process.StandardError.ReadToEnd(); - process.WaitForExit(60_000); // 60 сек таймаут + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); - foreach (var line in stdout.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) - lines.Add(line); + using var timeoutCts = new CancellationTokenSource(executionTimeout); + using var executionCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); - if (process.ExitCode != 0) - { - success = false; - errorMsg = stderr.Length > 0 ? stderr.Trim() : $"Exit code: {process.ExitCode}"; - if (!string.IsNullOrEmpty(stderr)) - lines.Add($"[ERROR] {stderr.Trim()}"); - } + try + { + await process.WaitForExitAsync(executionCts.Token); } - catch (Exception ex) when (!ct.IsCancellationRequested) + catch (OperationCanceledException) { - success = false; - errorMsg = ex.Message; - lines.Add($"[EXCEPTION] {ex.Message}"); + KillProcessTree(process); + await process.WaitForExitAsync(CancellationToken.None); + + if (ct.IsCancellationRequested) + throw new OperationCanceledException(ct); + + var timeoutMessage = $"PowerShell превысил таймаут {executionTimeout}."; + return PowerShellExecutionResult.Create( + [$"[ERROR] {timeoutMessage}"], timeoutMessage, + timedOut: timeoutCts.IsCancellationRequested); } - }, ct); - return (success, lines, errorMsg); + var stdout = await stdoutTask; + var stderr = await stderrTask; + + lines.AddRange(stdout.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)); + var actionableStderr = IsActionableStandardError(stderr); + var stderrLines = actionableStderr + ? stderr.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + : []; + lines.AddRange(stderrLines.Select(line => $"[ERROR] {line}")); + + var error = stderrLines.FirstOrDefault(); + if (process.ExitCode != 0 && error is null) + error = $"Exit code: {process.ExitCode}"; + + return PowerShellExecutionResult.Create(lines, error, process.ExitCode); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lines.Add($"[EXCEPTION] {ex.Message}"); + return PowerShellExecutionResult.Create(lines, ex.Message); + } + } + + private static TimeSpan ValidateTimeout(TimeSpan? timeout) + { + var value = timeout ?? DefaultExecutionTimeout; + if (value <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeout), "Таймаут должен быть положительным."); + return value; + } + + private static void KillProcessTree(System.Diagnostics.Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) { } + catch (System.ComponentModel.Win32Exception) { } + } + + private static bool IsActionableStandardError(string stderr) + { + if (string.IsNullOrWhiteSpace(stderr)) + return false; + + // Windows PowerShell 5.1 пишет progress-записи запуска модулей в stderr как CLIXML. + // Это служебный поток, а не ошибка команды. Реальные ErrorRecord помечены S="Error". + if (stderr.TrimStart().StartsWith("#< CLIXML", StringComparison.OrdinalIgnoreCase)) + { + return stderr.Contains("S=\"Error\"", StringComparison.OrdinalIgnoreCase) || + stderr.Contains("S='Error'", StringComparison.OrdinalIgnoreCase); + } + + return true; } public void Dispose() diff --git a/src/W-Fix.Core/W-Fix.Core.csproj b/src/W-Fix.Core/W-Fix.Core.csproj index 30cbe72..c804035 100644 --- a/src/W-Fix.Core/W-Fix.Core.csproj +++ b/src/W-Fix.Core/W-Fix.Core.csproj @@ -17,10 +17,11 @@ - - + + + diff --git a/tests/W-Fix.Core.Tests/PowerShellEngineTests.cs b/tests/W-Fix.Core.Tests/PowerShellEngineTests.cs new file mode 100644 index 0000000..70764fc --- /dev/null +++ b/tests/W-Fix.Core.Tests/PowerShellEngineTests.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using WFix.Core.Services; + +namespace WFix.Core.Tests; + +public class PowerShellEngineTests +{ + [Fact] + public async Task RunAsync_TreatsErrorMarkerAsFailure() + { + using var engine = new PowerShellEngine(); + + var result = await engine.RunAsync("Write-Output '[ERROR] embedded'"); + + Assert.False(result.Success); + Assert.Contains("[ERROR] embedded", result.Output); + } + + [Fact] + public async Task RunExternalAsync_ReturnsOutputForSuccessfulScript() + { + var result = await PowerShellEngine.RunExternalAsync("Write-Output '[OK] test'"); + + Assert.True(result.Success, result.Error); + Assert.Contains("[OK] test", result.Output); + Assert.Equal(0, result.ExitCode); + } + + [Fact] + public async Task RunExternalAsync_TreatsErrorMarkerAsFailure() + { + var result = await PowerShellEngine.RunExternalAsync("Write-Output '[ERROR] simulated'"); + + Assert.False(result.Success); + Assert.Contains("[ERROR] simulated", result.Output); + } + + [Fact] + public async Task RunExternalAsync_StopsProcessAfterTimeout() + { + var stopwatch = Stopwatch.StartNew(); + + var result = await PowerShellEngine.RunExternalAsync( + "Start-Sleep -Seconds 30", + timeout: TimeSpan.FromMilliseconds(500)); + + Assert.False(result.Success); + Assert.True(result.TimedOut); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task RunExternalAsync_ThrowsWhenUserCancels() + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + + await Assert.ThrowsAnyAsync(() => + PowerShellEngine.RunExternalAsync( + "Start-Sleep -Seconds 30", + cts.Token, + TimeSpan.FromMinutes(1))); + } +} diff --git a/tests/W-Fix.Core.Tests/PowerShellExecutionResultTests.cs b/tests/W-Fix.Core.Tests/PowerShellExecutionResultTests.cs new file mode 100644 index 0000000..b97527b --- /dev/null +++ b/tests/W-Fix.Core.Tests/PowerShellExecutionResultTests.cs @@ -0,0 +1,33 @@ +using WFix.Core.Models; + +namespace WFix.Core.Tests; + +public class PowerShellExecutionResultTests +{ + [Fact] + public void Create_WithErrorMarker_ReturnsFailure() + { + var result = PowerShellExecutionResult.Create(["[ERROR] команда завершилась с ошибкой"]); + + Assert.False(result.Success); + Assert.Equal("[ERROR] команда завершилась с ошибкой", result.Error); + } + + [Fact] + public void Create_WithWarningOnly_ReturnsSuccess() + { + var result = PowerShellExecutionResult.Create(["[WARN] требуется перезагрузка"]); + + Assert.True(result.Success); + Assert.Null(result.Error); + } + + [Fact] + public void Create_WithNonZeroExitCode_ReturnsFailure() + { + var result = PowerShellExecutionResult.Create([], exitCode: 5); + + Assert.False(result.Success); + Assert.Equal(5, result.ExitCode); + } +} diff --git a/tests/W-Fix.Core.Tests/Usings.cs b/tests/W-Fix.Core.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/W-Fix.Core.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/W-Fix.Core.Tests/W-Fix.Core.Tests.csproj b/tests/W-Fix.Core.Tests/W-Fix.Core.Tests.csproj new file mode 100644 index 0000000..89a09b3 --- /dev/null +++ b/tests/W-Fix.Core.Tests/W-Fix.Core.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0-windows + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + +