feat(toolbox): preconfig store (load fail-open, clear-pin, configured marker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-06-10 08:56:32 +01:00
parent 42d86734b0
commit bd1e2885df
4 changed files with 125 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
namespace SilverOS.Welcome.Core.Preconfig;
public interface IPreconfigStore
{
Preconfig? Load(); // null if missing/corrupt (fail-open)
void ClearPin(); // rewrite preconfig without the BitLocker pin
bool IsConfigured(); // configured marker present?
void MarkConfigured(); // write the configured marker
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json;
namespace SilverOS.Welcome.Core.Preconfig;
public sealed record BitlockerConfig { public bool Enable { get; init; } public string? Pin { get; init; } }
public sealed record AppsConfig { public bool UseFlavourDefaults { get; init; } = true; public IReadOnlyList<string>? Selected { get; init; } }
public sealed record Preconfig
{
public int SchemaVersion { get; init; } = 1;
public string Flavour { get; init; } = "";
public BitlockerConfig Bitlocker { get; init; } = new();
public AppsConfig Apps { get; init; } = new();
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
}

View File

@@ -0,0 +1,30 @@
using System.Text.Json;
namespace SilverOS.Welcome.Core.Preconfig;
public sealed class PreconfigStore(string dir) : IPreconfigStore
{
private string File_ => Path.Combine(dir, "preconfig.json");
private string Marker => Path.Combine(dir, "configured");
public Preconfig? Load()
{
try
{
if (!File.Exists(File_)) return null;
return JsonSerializer.Deserialize<Preconfig>(File.ReadAllText(File_), Preconfig.JsonOptions);
}
catch (JsonException) { return null; } // fail-open
}
public void ClearPin()
{
var p = Load();
if (p is null) return;
var stripped = p with { Bitlocker = p.Bitlocker with { Pin = null } };
File.WriteAllText(File_, JsonSerializer.Serialize(stripped, Preconfig.JsonOptions));
}
public bool IsConfigured() => File.Exists(Marker);
public void MarkConfigured() { Directory.CreateDirectory(dir); File.WriteAllText(Marker, "1"); }
}

View File

@@ -0,0 +1,64 @@
using System.IO;
using System.Text.Json;
using SilverOS.Welcome.Core.Preconfig;
using Xunit;
public class PreconfigTests
{
static string TempDir()
{
var d = Path.Combine(Path.GetTempPath(), "smpre-" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(d);
return d;
}
const string Sample = """
{ "schemaVersion":1, "flavour":"developer",
"bitlocker":{"enable":true,"pin":"246810"},
"apps":{"useFlavourDefaults":true} }
""";
[Fact]
public void Loads_flavour_and_pin()
{
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
var p = new PreconfigStore(dir).Load();
Assert.NotNull(p);
Assert.Equal("developer", p!.Flavour);
Assert.True(p.Bitlocker.Enable);
Assert.Equal("246810", p.Bitlocker.Pin);
Assert.True(p.Apps.UseFlavourDefaults);
}
[Fact]
public void Missing_or_bad_file_returns_null_not_throw()
{
Assert.Null(new PreconfigStore(TempDir()).Load()); // missing
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), "{ not json");
Assert.Null(new PreconfigStore(dir).Load()); // corrupt
}
[Fact]
public void ClearPin_rewrites_without_the_pin()
{
var dir = TempDir();
File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
var store = new PreconfigStore(dir);
store.ClearPin();
var reread = store.Load();
Assert.True(string.IsNullOrEmpty(reread!.Bitlocker.Pin));
Assert.Equal("developer", reread.Flavour); // rest preserved
}
[Fact]
public void Configured_marker_roundtrips()
{
var dir = TempDir();
var store = new PreconfigStore(dir);
Assert.False(store.IsConfigured());
store.MarkConfigured();
Assert.True(store.IsConfigured());
}
}