Files

310 lines
28 KiB
C#

using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using QSafe.Core;
using QSafe.Desktop;
internal static class Program
{
private static int passed;
private static void Assert(bool condition, string message = "Assertion failed") { if (!condition) throw new Exception(message); }
private static void Reject<T>(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception("Expected " + typeof(T).Name); }
private static async Task RejectAsync<T>(Func<Task> action) where T : Exception { try { await action(); } catch (T) { return; } throw new Exception("Expected " + typeof(T).Name); }
private static void Test(string title, Action action) { action(); passed++; Console.WriteLine("PASS " + title); }
private static async Task TestAsync(string title, Func<Task> action) { await action(); passed++; Console.WriteLine("PASS " + title); }
[STAThread]
private static int Main(string[] args)
{
try
{
if (args.FirstOrDefault() == "--lan-probe")
{
var config = Connection.Parse(File.ReadAllBytes(args[1]));
var binding = HomeWifiNetwork.Instance.Current(config); LanPolicy.Check(config, binding);
Console.WriteLine($"PASS home Wi-Fi confirmed: {binding.Ssid}, {binding.Address}/{binding.PrefixLength}, interface {binding.InterfaceIndex}");
using var client = new SyncClient(config, HomeWifiNetwork.Instance);
var encrypted = client.Download().GetAwaiter().GetResult(); VaultCrypto.Validate(encrypted);
Console.WriteLine($"PASS pinned LAN TLS and authenticated encrypted vault download ({encrypted.Length} bytes); no plaintext read, no writes");
return 0;
}
var artifact = Path.GetFullPath(args.FirstOrDefault() ?? "artifacts/windows-qa"); Directory.CreateDirectory(artifact);
CoreTests(artifact).GetAwaiter().GetResult(); DesktopTests(artifact);
Console.WriteLine($"RESULT {passed} checks passed"); return 0;
}
catch (Exception ex) { Console.Error.WriteLine(ex); return 1; }
}
private static async Task CoreTests(string artifact)
{
using var key = VaultCrypto.Create("Long synthetic test password 🔐");
var original = new VaultData(); var card = Templates.Create("login"); card.Title = "Test"; card.Fields[1].Value = "Secret"; original.Entries.Add(card);
var blob = Vault.Encrypt(original, key);
Test("AES-GCM roundtrip and random nonce", () => { Assert(Vault.Read(blob, key).Entries[0].Fields[1].Value == "Secret"); Assert(!blob.SequenceEqual(Vault.Encrypt(original, key))); });
Test("Wrong password and tampering rejected", () => { Reject<CryptographicException>(() => VaultCrypto.Unlock(blob, "wrong")); var corrupt = blob.ToArray(); corrupt[^1] ^= 1; Reject<CryptographicException>(() => Vault.Read(corrupt, key)); });
Test("Envelope bounds and iteration ceiling checked before KDF", () => { Reject<InvalidDataException>(() => VaultCrypto.Validate(new byte[8])); var corrupt = blob.ToArray(); Array.Fill(corrupt, (byte)127, 8, 4); Reject<InvalidDataException>(() => VaultCrypto.Unlock(corrupt, "wrong")); });
Test("Key disposal clears bytes", () => { var temporary = VaultCrypto.Create("test password"); temporary.Dispose(); Assert(temporary.Bytes.All(b => b == 0)); });
Test("Password generator character groups, options and bounds", () =>
{
for (int i = 0; i < 150; i++) { var p = VaultCrypto.Generate(32); Assert(p.Length == 32 && p.Any(char.IsUpper) && p.Any(char.IsLower) && p.Any(char.IsDigit) && p.Any(c => !char.IsLetterOrDigit(c))); Assert(VaultCrypto.Generate(16, false).All(char.IsLetterOrDigit)); }
Reject<ArgumentOutOfRangeException>(() => VaultCrypto.Generate(8));
});
Test("Password modes enforce lengths, digits, words and symbol choice", () =>
{
foreach (int size in new[] { 4, 12, 64, 128 })
for (int i = 0; i < 50; i++) { var p = PasswordGenerator.Generate(PasswordMode.Digits, size); Assert(p.Length == size && p.All(c => c is >= '0' and <= '9')); }
foreach (int size in new[] { 2, 3, 8, 12 })
foreach (bool symbols in new[] { true, false }) foreach (bool digits in new[] { true, false })
for (int i = 0; i < 50; i++)
{
var p = PasswordGenerator.Generate(PasswordMode.Memorable, size, symbols, digits);
Assert(Regex.Matches(p, "[A-Z]?[a-z]+").Count == size && p.Any(char.IsDigit) == digits && p.Any(c => !char.IsLetterOrDigit(c)) == symbols);
Assert(WholeWords(p, size), "Words must stay whole");
if (digits) Assert(p.Count(char.IsDigit) == 2);
}
Assert(PasswordGenerator.Generate(PasswordMode.Complex, 16, false).All(char.IsLetterOrDigit));
foreach (var (mode, size) in new[] { (PasswordMode.Complex, 15), (PasswordMode.Complex, 129), (PasswordMode.Digits, 3), (PasswordMode.Digits, 129), (PasswordMode.Memorable, 1), (PasswordMode.Memorable, 13) })
Reject<ArgumentOutOfRangeException>(() => PasswordGenerator.Generate(mode, size));
});
Test("Memorable digits and signs reach every word boundary in either order", () =>
{
var digitPositions = new bool[3]; var signPositions = new bool[3];
bool signBeforeDigit = false, signAfterDigit = false;
for (int i = 0; i < 1000; i++)
{
var p = PasswordGenerator.Generate(PasswordMode.Memorable, 2);
var words = Regex.Matches(p, "[A-Z][a-z]+"); Assert(words.Count == 2 && WholeWords(p, 2));
string[] boundaries = [p[..words[0].Index], p[(words[0].Index + words[0].Length)..words[1].Index], p[(words[1].Index + words[1].Length)..]];
for (int slot = 0; slot < 3; slot++)
{
digitPositions[slot] |= boundaries[slot].Any(char.IsDigit);
signPositions[slot] |= boundaries[slot].Any(c => !char.IsLetterOrDigit(c));
}
int sign = p.IndexOfAny("!@#$%&*+-=?".ToCharArray());
for (int j = 0; j < p.Length; j++) if (char.IsDigit(p[j])) { signBeforeDigit |= sign < j; signAfterDigit |= sign > j; }
}
Assert(digitPositions.All(p => p) && signPositions.All(p => p) && signBeforeDigit && signAfterDigit);
});
Test("Malformed vaults and duplicate entry IDs rejected", () =>
{
Reject<InvalidDataException>(() => Vault.Parse("{}"u8.ToArray())); var d = Vault.Copy(original); d.Entries.Add(Vault.Copy(card)); Reject<InvalidDataException>(() => Vault.Parse(Vault.Bytes(d)));
Reject<InvalidDataException>(() => Vault.Parse("{\"format\":1,\"id\":\"x\",\"entries\":[{\"fields\":[]}]}"u8.ToArray()));
Reject<InvalidDataException>(() => Vault.Parse("{\"format\":1,\"id\":\"x\",\"entries\":[null]}"u8.ToArray()));
});
Test("All eight templates use compatible secret types", () => { foreach (string kind in Templates.Kinds) { var e = Templates.Create(kind); Assert(e.Category == kind); Assert(e.Fields.All(f => f.Type != "password" || f.Secret)); } Assert(new Field { Type = "unknown" }.Secret); });
var local = Vault.Copy(original); var remote = Vault.Copy(original);
Test("Independent edits merge", () => { var extra = Templates.Create("note"); Vault.Update(local, extra); var changed = Vault.Copy(card); changed.Notes = "remote"; Vault.Update(remote, changed); var m = Vault.Merge(original, local, remote); Assert(m.Conflicts == 0 && m.Data.Entries.Count == 2 && m.Data.Entries.Single(e => e.Id == card.Id).Notes == "remote"); });
Test("Concurrent edits preserve both revisions", () => { var a = Vault.Copy(original); var b = Vault.Copy(original); var ea = Vault.Copy(card); ea.Notes = "A"; Vault.Update(a, ea); var eb = Vault.Copy(card); eb.Notes = "B"; Vault.Update(b, eb); var m = Vault.Merge(original, a, b); Assert(m.Conflicts == 1 && m.Data.Entries.Count == 2 && m.Data.Entries.Select(e => e.Notes).Order().SequenceEqual(new[] { "A", "B" })); });
Test("Delete/edit conflict retains edited card", () => { var a = Vault.Copy(original); var b = Vault.Copy(original); var ea = Vault.Copy(card); ea.Deleted = true; Vault.Update(a, ea); var eb = Vault.Copy(card); eb.Notes = "keep"; Vault.Update(b, eb); var m = Vault.Merge(original, a, b); Assert(m.Conflicts == 1 && m.Data.Entries.Single().Notes == "keep" && !m.Data.Entries.Single().Deleted); });
Test("Different vaults cannot merge", () => Reject<InvalidDataException>(() => Vault.Merge(null, original, new VaultData())));
Test("XML import retains all metadata and deduplicates", () =>
{
var xml = Encoding.UTF8.GetBytes("<database><label id='2' name='Работа'/><card title='Mail' star='true'><field name='Логин' type='login'>user</field><field name='Пароль' type='password'>a&amp;b</field><notes>note</notes><label_id>2</label_id><file name='a.txt'>YWJj</file><history><x/></history></card><card deleted='true'/><card template='true'/></database>");
var p = XmlImport.Preview(xml, new VaultData()); Assert(p.Added == 1 && p.Skipped == 2 && p.Attachments == 1 && p.Data.Entries[0].Fields[1].Value == "a&b" && p.Data.Entries[0].Tags.Single() == "Работа");
Assert(XmlImport.Preview(xml, p.Data).Duplicates == 1);
p.Data.Entries[0].Extra!.Remove("safeInCloudFingerprint"); Assert(XmlImport.Preview(xml, p.Data).Duplicates == 1);
var round = Vault.Read(Vault.Encrypt(p.Data, key), key); Assert(round.Entries[0].Extra!["safeInCloudXml"].GetString()!.Contains("<history>"));
});
Test("XML rejects XXE, DTD and foreign roots", () => { Reject<System.Xml.XmlException>(() => XmlImport.Preview(Encoding.UTF8.GetBytes("<!DOCTYPE database [<!ENTITY x SYSTEM 'file:///C:/Windows/win.ini'>]><database><card><notes>&x;</notes></card></database>"), original)); Reject<InvalidDataException>(() => XmlImport.Preview("<other/>"u8.ToArray(), original)); });
Test("Connection rejects HTTP, userinfo, fragments and malformed tokens", () =>
{
foreach (var url in new[] { "http://example.test/", "https://user@example.test/", "https://example.test/?x=1", "https://example.test/#fragment" }) Reject<InvalidDataException>(() => new Connection { Url = url, Token = new string('a', 43) }.Validate());
Reject<InvalidDataException>(() => Connection.Parse("{\"url\":null,\"token\":null}"u8.ToArray()));
});
var config = TestConnection();
var home = new FakeHomeNetwork();
var handler = new FakeServer(); using var client = new SyncClient(config, home, handler);
await TestAsync("Vault transport is never reached outside home Wi-Fi or local subnet", async () =>
{
foreach (var binding in new[] { new LanBinding("Other Wi-Fi", true, IPAddress.Parse("192.168.0.61"), 24, 3), new LanBinding("gen", false, IPAddress.Parse("192.168.0.61"), 24, 3), new LanBinding("gen", true, IPAddress.Parse("192.168.1.61"), 24, 3) })
{
home.Binding = binding; int before = handler.Requests; await RejectAsync<IOException>(() => client.Download()); Assert(handler.Requests == before);
}
home.Binding = FakeHomeNetwork.Home;
Assert(!LanPolicy.PrivateIpv4(IPAddress.Parse("8.8.8.8")) && !LanPolicy.PrivateIpv4(IPAddress.Parse("127.0.0.1")) && !LanPolicy.PrivateIpv4(IPAddress.Parse("169.254.1.1")) && !LanPolicy.PrivateIpv4(IPAddress.IPv6Loopback));
});
Test("Public vault URLs and hostnames rejected without DNS or transport", () =>
{
foreach (var url in new[] { "https://argus.kusoft.xyz/qsafe/", "https://8.8.8.8/", "https://localhost/", "https://192.168.0.185.evil.test/", "http://192.168.0.185/", "https://192.168.0.185/?x=1" })
{ var c = TestConnection(); c.Url = url; Reject<InvalidDataException>(c.Validate); }
var missing = TestConnection(); missing.HomeWifiSsid = ""; Reject<InvalidDataException>(missing.Validate);
missing = TestConnection(); missing.CertificateSha256 = ""; Reject<InvalidDataException>(missing.Validate);
});
await TestAsync("Sync creates remote using If-None-Match and ciphertext only", async () => { var result = await client.Sync(blob, null, key); Assert(handler.Blob!.SequenceEqual(blob) && handler.LastCondition == "*" && result.Data.Id == original.Id); });
config.Etag = VaultCrypto.Hash(blob);
var changedLocal = Vault.Copy(original); var edit = Vault.Copy(card); edit.Notes = "local change"; Vault.Update(changedLocal, edit); var changedBlob = Vault.Encrypt(changedLocal, key);
await TestAsync("Sync upload uses If-Match and confirmed SHA-256", async () => { await client.Sync(changedBlob, blob, key); Assert(handler.Blob!.SequenceEqual(changedBlob) && handler.LastCondition == '"' + VaultCrypto.Hash(blob) + '"'); });
config.Etag = VaultCrypto.Hash(changedBlob);
await TestAsync("Sync remote-only change downloaded without upload", async () => { handler.Blob = blob; var puts = handler.Puts; var r = await client.Sync(changedBlob, changedBlob, key); Assert(r.Envelope.SequenceEqual(blob) && handler.Puts == puts); });
await TestAsync("Sync concurrent changes preserve both entries", async () =>
{
var r = Vault.Copy(original); var e = Vault.Copy(card); e.Notes = "remote change"; Vault.Update(r, e); handler.Blob = Vault.Encrypt(r, key); config.Etag = VaultCrypto.Hash(blob);
var merged = await client.Sync(changedBlob, blob, key); Assert(merged.Conflicts == 1 && merged.Data.Entries.Count == 2);
});
await TestAsync("Sync detects disappeared remote and never recreates it", async () => { handler.Blob = null; var puts = handler.Puts; await RejectAsync<IOException>(() => client.Sync(blob, blob, key)); Assert(handler.Puts == puts); });
await TestAsync("Sync rejects corrupted remote without uploading", async () => { handler.Blob = blob.ToArray(); handler.Blob[^1] ^= 1; var puts = handler.Puts; await RejectAsync<CryptographicException>(() => client.Sync(changedBlob, blob, key)); Assert(handler.Puts == puts); });
await TestAsync("Sync validates response checksum", async () => { handler.Blob = blob; handler.BadHash = true; await RejectAsync<InvalidDataException>(() => client.Sync(blob, null, key)); handler.BadHash = false; });
await TestAsync("Sync retries conditional-write conflicts with fresh GET", async () => { handler.Blob = blob; config.Etag = VaultCrypto.Hash(blob); handler.ConflictOnce = true; var r = await client.Sync(changedBlob, blob, key); Assert(r.Envelope.SequenceEqual(changedBlob) && !handler.ConflictOnce); });
await TestAsync("Leaving home between GET and PUT blocks upload", async () =>
{
handler.Blob = blob; config.Etag = VaultCrypto.Hash(blob); int before = handler.Requests, puts = handler.Puts;
handler.AfterGet = () => home.Binding = FakeHomeNetwork.Home with { Ssid = "Away" };
try { await RejectAsync<IOException>(() => client.Sync(changedBlob, blob, key)); Assert(handler.Requests == before + 1 && handler.Puts == puts); }
finally { handler.AfterGet = null; home.Binding = FakeHomeNetwork.Home; }
});
Test("LAN HTTP handler disables proxy, redirects and certificate downloads", () =>
{
var method = typeof(SyncClient).GetMethod("CreateHandler", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
using var transport = (SocketsHttpHandler)method.Invoke(client, null)!;
Assert(!transport.UseProxy && !transport.AllowAutoRedirect && transport.SslOptions.CertificateChainPolicy!.DisableCertificateDownloads && transport.SslOptions.CertificateRevocationCheckMode == System.Security.Cryptography.X509Certificates.X509RevocationMode.NoCheck && transport.ConnectCallback != null);
});
await TestAsync("Bounded reads reject oversized streams", async () => { using var stream = new MemoryStream(new byte[20]); await RejectAsync<InvalidDataException>(() => SyncClient.Bounded(stream, 10)); });
if (File.Exists(Path.Combine(artifact, "java.qsafe")))
{
Test("Actual Android Java envelope opens in Windows", () =>
{
var javaBlob = File.ReadAllBytes(Path.Combine(artifact, "java.qsafe")); using var javaKey = VaultCrypto.Unlock(javaBlob, "Публичный тест 🔐 QSafe 2026"); var d = Vault.Read(javaBlob, javaKey);
Assert(d.Entries.Single().Title == "Привет из Android 🔐" && d.Extra!["futureRoot"].GetProperty("a").GetInt32() == 1 && d.Entries.Single().Fields.Single().Extra!["futureField"].GetBoolean());
d.Entries[0].Rev = "dotnet-revision"; File.WriteAllBytes(Path.Combine(artifact, "dotnet.qsafe"), Vault.Encrypt(d, javaKey));
});
}
}
private static void DesktopTests(string artifact)
{
var path = Path.Combine(Path.GetTempPath(), "QSafe-Tests", Guid.NewGuid().ToString("N")); var store = new LocalStore(path);
Test("Windows DPAPI seals credentials for current user", () => { var secret = Encoding.UTF8.GetBytes("synthetic test token"); var sealedBytes = DeviceProtection.Protect(secret); Assert(!sealedBytes.SequenceEqual(secret)); Assert(DeviceProtection.Unprotect(sealedBytes).SequenceEqual(secret)); });
Test("Atomic persistence keeps encrypted history and reloads config", () =>
{
using var key = VaultCrypto.Create("Test password for desktop"); var d = new VaultData(); for (int i = 0; i < 14; i++) { d.Entries.Add(new Entry { Title = "card " + i }); store.Save(d, key); }
Assert(Directory.GetFiles(Path.Combine(path, "backups"), "*.qsafe").Length == 10); Assert(Vault.Read(store.Read(), key).Entries.Count == 14);
var config = TestConnection(); config.Token = new string('b', 43); store.Configure(config); store.MarkSynced(store.Read()); var loaded = new LocalStore(path); Assert(loaded.Config.Etag == VaultCrypto.Hash(store.Read()) && loaded.Connected); Assert(!Encoding.UTF8.GetString(File.ReadAllBytes(Path.Combine(path, "connection.enc"))).Contains(new string('b', 43)));
});
Test("Changing server resets ancestor and ETag", () => { var config = TestConnection(); config.Url = "https://192.168.0.186:18443/qsafe/"; store.Configure(config); Assert(store.ReadBase() == null && store.Config.Etag == ""); });
Test("WPF resources and synthetic demo render at desktop and minimum size", () =>
{
var app = new App(); app.InitializeComponent();
var demoStore = new LocalStore(Path.Combine(Path.GetTempPath(), "QSafe-Demo", Guid.NewGuid().ToString("N")));
var main = new MainWindow(demoStore, true); app.MainWindow = main;
var search = (TextBox)main.FindName("Search"); var cards = (ListBox)main.FindName("Cards");
Test("Search finds non-secret fields and excludes passwords", () =>
{
search.Text = "GITHUB.COM"; Assert(cards.Items.Count == 1);
search.Text = "Demo-Only-Password"; Assert(cards.Items.Count == 0);
search.Clear(); search.Text = "GitHub"; search.Clear();
});
Test("Background operations do not reset the inactivity timer", () =>
{
var field = typeof(MainWindow).GetField("lastInput", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var old = DateTime.UtcNow.AddSeconds(-80); field.SetValue(main, old);
var work = typeof(MainWindow).GetMethod("Work", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
((Task)work.Invoke(main, ["Test", (Func<Task>)(() => Task.CompletedTask)])!).GetAwaiter().GetResult(); Assert((DateTime)field.GetValue(main)! == old);
});
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-desktop.png"), 1360, 810);
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-desktop-compact.png"), 1080, 660);
var label = ((StackPanel)main.FindName("Details")).Children.OfType<TextBlock>().First().Text; Assert(label.Contains("САЙТЫ"));
// Calls the application's lock logic in-process; no OS input or user vault is involved.
search.Text = "GitHub";
typeof(MainWindow).GetMethod("Lock", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.Invoke(main, null);
Assert(cards.Items.Count == 0 && ((StackPanel)main.FindName("Details")).Children.Count == 0);
Render((FrameworkElement)main.Content, Path.Combine(artifact, "QSafe-unlock.png"), 1360, 810);
Test("Generator preview, mode lengths, cancel and explicit use", () =>
{
main.Show(); main.Hide();
string? used = null, expected = null; Exception? failure = null;
var generator = new PasswordGeneratorDialog(main, value => used = value);
var preview = Descendants<TextBox>(generator.Body).Single();
var slider = Descendants<Slider>(generator.Body).Single();
var modes = Descendants<RadioButton>(generator.Body).ToArray();
generator.Loaded += (_, _) =>
{
try
{
Assert(preview.Text.Length == 24 && used == null);
slider.Value = 32; Assert(preview.Text.Length == 32);
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-complex.png"), 620, 680);
modes[1].IsChecked = true; slider.Value = 4; Assert(preview.Text.Length == 4 && preview.Text.All(char.IsDigit));
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-digits.png"), 620, 680);
modes[2].IsChecked = true; slider.Value = 12; Assert(Regex.Matches(preview.Text, "[A-Z]?[a-z]+").Count == 12);
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-memorable.png"), 620, 680);
Assert(slider.Minimum == 2); slider.Value = 2;
var symbols = Descendants<CheckBox>(generator.Body).Single(c => ((string)c.Content).StartsWith("Специальные"));
var digits = Descendants<CheckBox>(generator.Body).Single(c => ((string)c.Content).StartsWith("Добавлять цифры"));
Assert(symbols.Visibility == Visibility.Visible && digits.Visibility == Visibility.Visible);
Assert(WholeWords(preview.Text, 2) && preview.Text.Count(char.IsDigit) == 2 && preview.Text.Count(c => !char.IsLetterOrDigit(c)) == 1);
Render((FrameworkElement)generator.Content, Path.Combine(artifact, "QSafe-generator-two-words.png"), 620, 680);
digits.IsChecked = false; Assert(!preview.Text.Any(char.IsDigit));
symbols.IsChecked = false; Assert(Regex.IsMatch(preview.Text, @"^[A-Z][a-z]+[A-Z][a-z]+$"));
digits.IsChecked = true; symbols.IsChecked = true;
modes[0].IsChecked = true; Assert(slider.Minimum == 16 && slider.Value == 32 && preview.Text.Length == 32);
modes[1].IsChecked = true; Assert(slider.Minimum == 4 && slider.Value == 4);
modes[2].IsChecked = true; Assert(slider.Value == 2);
expected = preview.Text; Assert(used == null);
generator.Actions.Children.OfType<Button>().Single(b => (string)b.Content == "Использовать пароль").RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
}
catch (Exception ex) { failure = ex; generator.Close(); }
};
Assert(generator.ShowDialog() == true); if (failure != null) throw failure;
Assert(used == expected && preview.Text == "");
used = null; var canceled = new PasswordGeneratorDialog(main, value => used = value);
canceled.Loaded += (_, _) => canceled.Close(); canceled.ShowDialog(); Assert(used == null);
});
main.Close();
});
}
private static void Render(FrameworkElement visual, string path, int width, int height)
{
visual.Measure(new Size(width, height)); visual.Arrange(new Rect(0, 0, width, height)); visual.UpdateLayout();
var target = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); target.Render(visual);
var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(target)); using var stream = File.Create(path); encoder.Save(stream);
}
private static IEnumerable<T> Descendants<T>(DependencyObject parent) where T : DependencyObject
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T match) yield return match;
foreach (var item in Descendants<T>(child)) yield return item;
}
}
private static bool WholeWords(string value, int count)
{
const string gap = @"[0-9!@#$%&*+\-=?]*";
return Regex.IsMatch(value, "^" + gap + "[A-Z][a-z]+(?:" + gap + "[A-Z][a-z]+){" + (count - 1) + "}" + gap + "$");
}
private sealed class FakeServer : HttpMessageHandler
{
public byte[]? Blob; public int Puts, Requests; public string LastCondition = ""; public bool BadHash, ConflictOnce; public Action? AfterGet;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests++; Assert(request.RequestUri!.AbsoluteUri == "https://192.168.0.185:18443/qsafe/v1/vault"); Assert(request.Headers.Authorization?.Scheme == "Bearer");
if (request.Method == HttpMethod.Get)
{
if (Blob == null) return new(HttpStatusCode.NotFound);
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Blob) }; response.Headers.ETag = new EntityTagHeaderValue('"' + (BadHash ? new string('0', 64) : VaultCrypto.Hash(Blob)) + '"'); AfterGet?.Invoke(); return response;
}
Puts++; LastCondition = request.Headers.Contains("If-Match") ? request.Headers.GetValues("If-Match").Single() : request.Headers.GetValues("If-None-Match").Single();
if (ConflictOnce) { ConflictOnce = false; return new(HttpStatusCode.PreconditionFailed); }
if ((Blob == null && LastCondition != "*") || (Blob != null && LastCondition != '"' + VaultCrypto.Hash(Blob) + '"')) return new(HttpStatusCode.PreconditionFailed);
Blob = await request.Content!.ReadAsByteArrayAsync(ct); var result = new HttpResponseMessage(HttpStatusCode.OK); result.Headers.ETag = new EntityTagHeaderValue('"' + VaultCrypto.Hash(Blob) + '"'); return result;
}
}
private static Connection TestConnection() => new() { Url = "https://192.168.0.185:18443/qsafe/", Token = new string('a', 43), HomeWifiSsid = "gen", CertificateSha256 = new string('a', 64) };
private sealed class FakeHomeNetwork : ILanNetwork
{
public static readonly LanBinding Home = new("gen", true, IPAddress.Parse("192.168.0.61"), 24, 3);
public LanBinding Binding = Home;
public LanBinding Current(Connection connection) => Binding;
}
}