diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyProgress.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyProgress.cs new file mode 100644 index 0000000..afdd0c1 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyProgress.cs @@ -0,0 +1,2 @@ +namespace SilverOS.Welcome.Core.Apply; +public sealed record ApplyProgress(string Stage, int Percent); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs new file mode 100644 index 0000000..a6b0cd9 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs @@ -0,0 +1,4 @@ +using SilverOS.Welcome.Core.Flavours; +namespace SilverOS.Welcome.Core.Apply; +public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password, + string AdminPassword, string BitLockerPin, string BootstrapUser); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs new file mode 100644 index 0000000..866381e --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using SilverOS.Welcome.Core.Flavours; +namespace SilverOS.Welcome.Core.Apply; + +public sealed class ApplyService(IProcessRunner runner, IAccountService accounts, + IBitLockerService bitlocker, IBootstrapService bootstrap, string hardeningDir) : IApplyService +{ + public async Task RunAsync(ApplyRequest req, IProgress progress, CancellationToken ct = default) + { + progress.Report(new("Applying hardening", 10)); + var mods = string.Join(",", req.Flavour.Hardening.Modules.Select(m => $"'{m}'")); + var pjson = JsonSerializer.Serialize(req.Flavour.Hardening.Params).Replace("\"", "\\\""); + var script = Path.Combine(hardeningDir, "Invoke-Hardening.ps1"); + var res = await runner.RunAsync("powershell.exe", + $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" -Modules {mods} -ParamsJson \"{pjson}\"", ct); + if (res.ExitCode != 0) throw new InvalidOperationException($"hardening failed: {res.StdErr}"); + + progress.Report(new("Creating your account", 55)); + await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct); + + progress.Report(new("Encrypting the disk", 75)); + await bitlocker.EnableAsync(req.BitLockerPin, ct); + + progress.Report(new("Finishing up", 95)); + await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success + progress.Report(new("Done", 100)); + } +} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IApplyService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IApplyService.cs new file mode 100644 index 0000000..2ac0078 --- /dev/null +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IApplyService.cs @@ -0,0 +1,2 @@ +namespace SilverOS.Welcome.Core.Apply; +public interface IApplyService { Task RunAsync(ApplyRequest req, IProgress progress, CancellationToken ct = default); } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs new file mode 100644 index 0000000..ea26127 --- /dev/null +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs @@ -0,0 +1,42 @@ +using Moq; +using SilverOS.Welcome.Core.Apply; +using SilverOS.Welcome.Core.Flavours; +using Xunit; + +public class ApplyServiceTests +{ + [Fact] + public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last() + { + var order = new List(); + var run = new Mock(); + run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, a, _) => { if (a.Contains("Invoke-Hardening")) order.Add("modules"); }) + .ReturnsAsync(new ProcessResult(0, "", "")); + var acct = new Mock(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask); + var bl = new Mock(); bl.Setup(b => b.EnableAsync(It.IsAny(),It.IsAny())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); + var boot = new Mock(); boot.Setup(b => b.TearDownAsync(It.IsAny(),It.IsAny())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask); + + var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard"); + var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } }; + var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap"); + var progress = new List(); + + await sut.RunAsync(req, new Progress(p => progress.Add(p.Stage))); + + Assert.Equal(new[]{"modules","accounts","bitlocker","bootstrap"}, order); + Assert.Contains("Applying hardening", progress); + } + + [Fact] + public async Task Does_not_tear_down_bootstrap_if_account_creation_fails() + { + var run = new Mock(); run.Setup(r => r.RunAsync(It.IsAny(),It.IsAny(),It.IsAny())).ReturnsAsync(new ProcessResult(0,"","")); + var acct = new Mock(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + var bl = new Mock(); var boot = new Mock(); + var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard"); + var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap"); + await Assert.ThrowsAsync(() => sut.RunAsync(req, new Progress(_ => {}))); + boot.Verify(b => b.TearDownAsync(It.IsAny(), It.IsAny()), Times.Never); + } +}