fix(welcome): apply services check PowerShell exit codes + throw on failure (no more silent privileged-op failures)
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m30s

This commit is contained in:
sysadmin
2026-06-09 11:21:46 +01:00
parent a47345887c
commit 2b2214c124
5 changed files with 82 additions and 11 deletions

View File

@@ -6,13 +6,20 @@ public sealed class AccountService(IProcessRunner runner) : IAccountService
// Daily account = Standard User (Users group only — NOT Administrators).
await Ps($"$p=ConvertTo-SecureString '{Esc(password)}' -AsPlainText -Force; " +
$"New-LocalUser -Name '{Esc(user)}' -Password $p -FullName '{Esc(user)}' -AccountNeverExpires; " +
$"Add-LocalGroupMember -Group 'Users' -Member '{Esc(user)}'", ct);
$"Add-LocalGroupMember -Group 'Users' -Member '{Esc(user)}'", "Daily account creation", ct);
// Separate elevation account.
await Ps($"$a=ConvertTo-SecureString '{Esc(adminPassword)}' -AsPlainText -Force; " +
$"New-LocalUser -Name 'SilverOS Admin' -Password $a -AccountNeverExpires; " +
$"Add-LocalGroupMember -Group 'Administrators' -Member 'SilverOS Admin'", ct);
$"Add-LocalGroupMember -Group 'Administrators' -Member 'SilverOS Admin'", "Admin account creation", ct);
}
// $ErrorActionPreference='Stop' turns the (otherwise non-terminating) cmdlet errors into a
// non-zero exit so EnsureSuccess can surface them instead of silently continuing.
private async Task Ps(string script, string operation, CancellationToken ct)
{
var r = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {script}\"", ct);
r.EnsureSuccess(operation);
}
private Task Ps(string script, CancellationToken ct) =>
runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{script}\"", ct);
private static string Esc(string s) => s.Replace("'", "''");
}

View File

@@ -1,7 +1,7 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService
{
public Task EnableAsync(string pin, CancellationToken ct = default)
public async Task EnableAsync(string pin, CancellationToken ct = default)
{
var p = pin.Replace("'", "''");
@@ -32,9 +32,15 @@ public sealed class BitLockerService(IProcessRunner runner) : IBitLockerService
"$kp=(Get-BitLockerVolume -MountPoint $mp).KeyProtector; ",
"if ($kp | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }) { ",
"$kp | Where-Object { $_.KeyProtectorType -eq 'Tpm' } | ForEach-Object { ",
"Remove-BitLockerKeyProtector -MountPoint $mp -KeyProtectorId $_.KeyProtectorId | Out-Null } }");
"Remove-BitLockerKeyProtector -MountPoint $mp -KeyProtectorId $_.KeyProtectorId | Out-Null } }; ",
// Outcome check: fail loudly (non-zero exit) if a TPM+PIN protector is not present at
// the end — this is what actually matters (a benign non-terminating warning alone
// must not pass, and a real failure must not stay silent).
"$ok=(Get-BitLockerVolume -MountPoint $mp).KeyProtector | Where-Object { $_.KeyProtectorType -eq 'TpmPin' }; ",
"if (-not $ok) { Write-Error 'TPM+PIN protector not present after enrollment'; exit 1 }");
return runner.RunAsync("powershell.exe",
var r = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"{script}\"", ct);
r.EnsureSuccess("BitLocker enrollment");
}
}

View File

@@ -4,12 +4,21 @@ public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService
public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default)
{
const string key = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'";
// DefaultPassword may legitimately be absent → keep its per-cmdlet -EA SilentlyContinue.
await Ps($"Set-ItemProperty {key} -Name AutoAdminLogon -Value 0; " +
$"Remove-ItemProperty {key} -Name DefaultPassword -EA SilentlyContinue", ct);
$"Remove-ItemProperty {key} -Name DefaultPassword -EA SilentlyContinue", "Disable auto-logon", ct);
var u = Esc(bootstrapUser);
await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct);
await Ps($"Remove-LocalUser -Name '{u}' -EA SilentlyContinue", "Remove bootstrap account", ct);
}
private static string Esc(string s) => s.Replace("'", "''");
private Task Ps(string s, CancellationToken ct) =>
runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct);
// $ErrorActionPreference='Stop' surfaces unexpected hard errors (e.g. a bad registry path);
// the intentional per-cmdlet -EA SilentlyContinue above still overrides it for the known
// best-effort cleanups.
private async Task Ps(string s, string operation, CancellationToken ct)
{
var r = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {s}\"", ct);
r.EnsureSuccess(operation);
}
}

View File

@@ -0,0 +1,20 @@
namespace SilverOS.Welcome.Core.Apply;
internal static class ProcessResultExtensions
{
/// <summary>
/// Throws an <see cref="InvalidOperationException"/> with a scrubbed message when the
/// process exited non-zero, so a failed privileged step surfaces to the wizard (and a
/// failed apply does not proceed to bootstrap teardown) instead of failing silently.
/// </summary>
public static void EnsureSuccess(in this ProcessResult result, string operation)
{
if (result.ExitCode == 0) return;
var firstLine = (result.StdErr ?? string.Empty)
.Split('\n')
.Select(l => l.Trim())
.FirstOrDefault(l => l.Length > 0) ?? string.Empty;
if (firstLine.Length > 200) firstLine = firstLine[..200];
throw new InvalidOperationException($"{operation} failed (exit {result.ExitCode}): {firstLine}");
}
}

View File

@@ -11,6 +11,35 @@ public class ApplyServicesTests
return m;
}
private static Mock<IProcessRunner> Fail()
{
var m = new Mock<IProcessRunner>();
m.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult(1, "", "the operation failed"));
return m;
}
[Fact]
public async Task AccountService_throws_on_nonzero_exit()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new AccountService(Fail().Object).CreateAccountsAsync("alice", "pw", "apw"));
}
[Fact]
public async Task BitLockerService_throws_on_nonzero_exit()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new BitLockerService(Fail().Object).EnableAsync("123456"));
}
[Fact]
public async Task BootstrapService_throws_on_nonzero_exit()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new BootstrapService(Fail().Object).TearDownAsync("sm-bootstrap"));
}
[Fact]
public async Task AccountService_creates_standard_daily_and_admin()
{