From db9f8d46ed122b7f20a53f52ccca6a4cca4e75c6 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Mon, 19 Oct 2026 17:23:29 -0700 Subject: [PATCH 001/333] v1.8.4: Exit silent installs with 1618 when KillerScan is running so deployment tools retry --- CHANGELOG.md | 1 + Services/InstallerExitCodes.cs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 061185fd..114a342b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ### Fixed - The shortcuts card shows shadows at its edges when more content is out of view. +- A silent install or update that finds KillerScan running exits with 1618, which Intune, Configuration Manager and RMM tools retry, instead of a general failure. ## [1.8.3] - 2026-10-19 diff --git a/Services/InstallerExitCodes.cs b/Services/InstallerExitCodes.cs index dce7284c..c817cb54 100644 --- a/Services/InstallerExitCodes.cs +++ b/Services/InstallerExitCodes.cs @@ -35,6 +35,9 @@ internal static int FromException(Exception error) int code = current is Win32Exception native ? native.NativeErrorCode : current is IOException ? current.HResult & 0xffff : 0; if (code == 39 || code == DiskFull) return DiskFull; + // Sharing or lock violation: the copy being replaced is running. Report it as busy + // so Intune, Configuration Manager and RMM tools retry instead of failing. + if (code == 32 || code == 33) return Busy; if (code == AccessDenied || code == Cancelled || code == Busy || code == SecurityRejected) return code; } From 829c1869a427b5b2bece2b3ac12f5ca0eb28e935 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 06:56:47 -0700 Subject: [PATCH 002/333] v1.8.4: Keep sidebar fades clear of scrollbars --- CHANGELOG.md | 1 + Shell/SidebarFades.cs | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 114a342b..274eb547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ## [1.8.4] - Unreleased ### Fixed +- Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. - The shortcuts card shows shadows at its edges when more content is out of view. - A silent install or update that finds KillerScan running exits with 1618, which Intune, Configuration Manager and RMM tools retry, instead of a general failure. diff --git a/Shell/SidebarFades.cs b/Shell/SidebarFades.cs index f26dc731..31327238 100644 --- a/Shell/SidebarFades.cs +++ b/Shell/SidebarFades.cs @@ -44,14 +44,16 @@ private void UpdateSidebarFades() private static void UpdateSidebarFade(ListBox list) { + list.OpacityMask = null; if (FindDescendant(list) is not ScrollViewer scroll || list.ActualHeight <= 0) { - list.OpacityMask = null; return; } + if (scroll.Template.FindName("PART_ScrollContentPresenter", scroll) + is not ScrollContentPresenter viewport) return; if (Application.Current?.TryFindResource("EdgeFadeOpacity") is double themeFade && themeFade <= 0) { - list.OpacityMask = null; + viewport.OpacityMask = null; return; } @@ -60,12 +62,17 @@ private static void UpdateSidebarFade(ListBox list) bool fadeBottom = overflow && scroll.VerticalOffset < scroll.ScrollableHeight - 0.5; if (!fadeTop && !fadeBottom) { - list.OpacityMask = null; + viewport.OpacityMask = null; return; } - double edge = Math.Min(0.45, SidebarFadePx / list.ActualHeight); - var mask = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(0, 1) }; + double edge = Math.Min(0.45, SidebarFadePx / Math.Max(1, viewport.ActualHeight)); + var mask = new LinearGradientBrush + { + MappingMode = BrushMappingMode.Absolute, + StartPoint = new Point(0, 0), + EndPoint = new Point(0, viewport.ActualHeight) + }; if (fadeTop) { mask.GradientStops.Add(new GradientStop(Colors.Transparent, 0)); @@ -79,7 +86,7 @@ private static void UpdateSidebarFade(ListBox list) } else mask.GradientStops.Add(new GradientStop(Colors.Black, 1)); mask.Freeze(); - list.OpacityMask = mask; + viewport.OpacityMask = mask; } } } From 2ba9b82f61a2117cc73975f62577853b68526a14 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 13:37:03 -0700 Subject: [PATCH 003/333] v1.8.4: Fail package validation when its last result remains failed --- .github/workflows/chocolatey-release.yml | 5 +++++ CHANGELOG.md | 1 + 2 files changed, 6 insertions(+) diff --git a/.github/workflows/chocolatey-release.yml b/.github/workflows/chocolatey-release.yml index 9f143cdb..33e91f1c 100644 --- a/.github/workflows/chocolatey-release.yml +++ b/.github/workflows/chocolatey-release.yml @@ -99,6 +99,7 @@ jobs: $feed = "https://community.chocolatey.org/api/v2/Packages(Id='killerscan',Version='$env:CHOCO_VERSION')" $repushed = $false $pendingSinceRepush = $false + $lastKnownState = '' $deadline = (Get-Date).AddMinutes(160) while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds 300 @@ -109,6 +110,7 @@ jobs: continue } $state = [regex]::Match($entry, '(\w+)').Groups[1].Value + if ($state) { $lastKnownState = $state } Write-Host "$(Get-Date -Format HH:mm:ss) validation: $(if ($state) { $state } else { 'not shown' })" if ($state -like 'Pass*') { exit 0 } if ($repushed -and $state -and $state -notlike 'Fail*') { $pendingSinceRepush = $true } @@ -122,6 +124,9 @@ jobs: throw "Chocolatey validation failed twice. See $page" } } + if ($lastKnownState -like 'Fail*') { + throw "Chocolatey validation remained failed after re-push. See $page" + } # Chocolatey's queue often takes longer than this. The package is already pushed, so a # validation that is still pending is a warning, not a failed release. Write-Host "::warning::Chocolatey validation was still pending after 160 minutes. The package was pushed. See $page" diff --git a/CHANGELOG.md b/CHANGELOG.md index 274eb547..f8ad41d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. - The shortcuts card shows shadows at its edges when more content is out of view. - A silent install or update that finds KillerScan running exits with 1618, which Intune, Configuration Manager and RMM tools retry, instead of a general failure. +- Chocolatey publishing reports failure when validation is still failing after a retry. ## [1.8.3] - 2026-10-19 From 5e28191af8e47714c6f1c570557e57a43fc94afb Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:07:13 -0700 Subject: [PATCH 004/333] v1.8.4: Fit native resize minimums within the monitor work area --- CHANGELOG.md | 1 + Shell/WindowChrome.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ad41d0..9030937d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ## [1.8.4] - Unreleased ### Fixed +- Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. - The shortcuts card shows shadows at its edges when more content is out of view. - A silent install or update that finds KillerScan running exits with 1618, which Intune, Configuration Manager and RMM tools retry, instead of a general failure. diff --git a/Shell/WindowChrome.cs b/Shell/WindowChrome.cs index 685f5154..dbcbb98c 100644 --- a/Shell/WindowChrome.cs +++ b/Shell/WindowChrome.cs @@ -273,9 +273,9 @@ private void WmGetMinMaxInfo(IntPtr hwnd, IntPtr lParam) double sx = src?.CompositionTarget?.TransformToDevice.M11 ?? 1.0; double sy = src?.CompositionTarget?.TransformToDevice.M22 ?? 1.0; if (!double.IsNaN(MinWidth) && MinWidth > 0) - mmi.ptMinTrackSize.x = (int)Math.Ceiling(MinWidth * sx); + mmi.ptMinTrackSize.x = Math.Min(mmi.ptMaxSize.x, (int)Math.Ceiling(MinWidth * sx)); if (!double.IsNaN(MinHeight) && MinHeight > 0) - mmi.ptMinTrackSize.y = (int)Math.Ceiling(MinHeight * sy); + mmi.ptMinTrackSize.y = Math.Min(mmi.ptMaxSize.y, (int)Math.Ceiling(MinHeight * sy)); Marshal.StructureToPtr(mmi, lParam, true); } From 221959aa9cff4c6b5d93732af4895c03330c8fa5 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:09:02 -0700 Subject: [PATCH 005/333] v1.8.4: Serialize history transactions across app instances --- CHANGELOG.md | 1 + Services/HistoryArchive.cs | 114 +++++++++++++----- .../SpeedTest.Tests/HistoryRetentionTests.cs | 70 +++++++++++ tests/SpeedTest.Tests/Program.cs | 2 + 4 files changed, 160 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9030937d..ca3783a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ## [1.8.4] - Unreleased ### Fixed +- Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. - The shortcuts card shows shadows at its edges when more content is out of view. diff --git a/Services/HistoryArchive.cs b/Services/HistoryArchive.cs index 53e5b1b8..3f8f4517 100644 --- a/Services/HistoryArchive.cs +++ b/Services/HistoryArchive.cs @@ -1,5 +1,8 @@ using System.IO; using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using System.Threading; namespace KillerScan.Services { @@ -9,21 +12,44 @@ internal sealed class HistoryArchive { private readonly string _path; private bool _readable = true; + private string? _revision; + private readonly List _pending = []; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; internal List Entries { get; private set; } = []; internal HistoryRetention Policy { get; private set; } = new(HistoryRetentionMode.Count); - internal HistoryArchive(string path) => _path = path; + internal HistoryArchive(string path) => _path = Path.GetFullPath(path); internal void Load(DateTimeOffset now) + { + try + { + Locked(() => + { + ReadCurrent(); + if (_readable && Policy.Mode == HistoryRetentionMode.Time) + { + var kept = Policy.Retained(Entries, now); + if (kept.Count != Entries.Count) { Persist(kept, Policy); Entries = kept; } + } + return true; + }); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private void ReadCurrent() { _readable = true; + _revision = null; Entries = []; Policy = new(HistoryRetentionMode.Count); - if (!File.Exists(_path)) return; + if (!File.Exists(_path)) { Entries.AddRange(_pending); return; } try { - using var json = JsonDocument.Parse(File.ReadAllText(_path)); + _revision = File.ReadAllText(_path); + using var json = JsonDocument.Parse(_revision); if (json.RootElement.ValueKind == JsonValueKind.Array) { Entries = JsonSerializer.Deserialize>(json.RootElement.GetRawText()) ?? []; @@ -56,43 +82,77 @@ internal void Load(DateTimeOffset now) Policy = new(HistoryRetentionMode.SaveAll); return; } - if (Policy.Mode == HistoryRetentionMode.Time) - { - var kept = Policy.Retained(Entries, now); - if (kept.Count != Entries.Count) - { - try { Persist(kept, Policy); Entries = kept; } catch (IOException) { } - catch (UnauthorizedAccessException) { } - } - } + Entries.AddRange(_pending); } internal bool Apply(HistoryRetention policy, DateTimeOffset now, Func confirmRemoval) { - var original = Entries; - var kept = policy.Retained(original, now); - int removed = original.Count - kept.Count; - if (removed > 0 && !confirmRemoval(removed)) return false; - // A scan can finish while a modal confirmation runs its dispatcher. - if (!ReferenceEquals(original, Entries)) return Apply(policy, now, confirmRemoval); - Persist(kept, policy); - Entries = kept; - Policy = policy; - return true; + if (!policy.IsValid) throw new ArgumentOutOfRangeException(nameof(policy)); + while (true) + { + string? revision = Locked(() => { ReadCurrent(); EnsureReadable(); return _revision; }); + var original = Entries; + var kept = policy.Retained(original, now); + int removed = original.Count - kept.Count; + // Never hold the process lock while a confirmation pumps the UI dispatcher. + if (removed > 0 && !confirmRemoval(removed)) return false; + if (Locked(() => + { + bool changedInSession = !ReferenceEquals(original, Entries); + ReadCurrent(); EnsureReadable(); + if (changedInSession || revision != _revision) return false; + Persist(kept, policy); + Entries = kept; + Policy = policy; + _pending.Clear(); + return true; + })) return true; + } } internal void Append(ScanHistoryEntry entry, DateTimeOffset now) { - var kept = Policy.Retained(Entries.Concat([entry]), now); - // A scan result still belongs in this session if its disk write fails. - Entries = kept; - try { Persist(kept, Policy); } catch (IOException) { } + _pending.Add(entry); + try + { + Locked(() => + { + ReadCurrent(); EnsureReadable(); + var kept = Policy.Retained(Entries, now); + Persist(kept, Policy); + Entries = kept; + _pending.Clear(); + return true; + }); + } + catch (IOException) { Entries = Policy.Retained(Entries.Concat(_pending).Distinct(), now); } catch (UnauthorizedAccessException) { } } - private void Persist(List entries, HistoryRetention policy) + private void EnsureReadable() { if (!_readable) throw new IOException("The existing scan history file could not be read."); + } + + private T Locked(Func operation) + { + using var hash = SHA256.Create(); + string key = BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(_path.ToUpperInvariant()))).Replace("-", ""); + using var mutex = new Mutex(false, "Global\\KillerScan.History." + key); + bool owned = false; + try + { + try { owned = mutex.WaitOne(TimeSpan.FromSeconds(2)); } + catch (AbandonedMutexException) { owned = true; } + if (!owned) throw new IOException("The scan history file is busy. Try again."); + return operation(); + } + finally { if (owned) mutex.ReleaseMutex(); } + } + + private void Persist(List entries, HistoryRetention policy) + { + EnsureReadable(); string directory = Path.GetDirectoryName(_path)!; Directory.CreateDirectory(directory); string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp"; diff --git a/tests/SpeedTest.Tests/HistoryRetentionTests.cs b/tests/SpeedTest.Tests/HistoryRetentionTests.cs index c79cf527..ea03be98 100644 --- a/tests/SpeedTest.Tests/HistoryRetentionTests.cs +++ b/tests/SpeedTest.Tests/HistoryRetentionTests.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Reflection; using System.Text.Json; +using System.Diagnostics; +using System.Threading; internal static class HistoryRetentionTests { @@ -14,6 +16,23 @@ internal static class HistoryRetentionTests private static object? Property(object value, string name) => value.GetType().GetProperty(name, Instance)!.GetValue(value); private static void Require(bool condition, string message) { _checks++; if (!condition) throw new InvalidOperationException(message); } + internal static void WriteConcurrent(string path, string target, string gate) + { + var assembly = typeof(KillerScan.App).Assembly; + var type = assembly.GetType("KillerScan.Services.HistoryArchive", true)!; + var archive = Activator.CreateInstance(type, Instance, null, new object[] { path }, null)!; + var now = DateTimeOffset.Now; + Call(archive, "Load", now); + using var ready = EventWaitHandle.OpenExisting(gate + "." + target); + using var start = EventWaitHandle.OpenExisting(gate); + ready.Set(); + Require(start.WaitOne(15000), "Concurrent writer receives its start signal."); + var entry = Activator.CreateInstance(assembly.GetType("KillerScan.Services.ScanHistoryEntry", true)!)!; + entry.GetType().GetProperty("Target")!.SetValue(entry, target); + entry.GetType().GetProperty("ScannedAt")!.SetValue(entry, now); + Call(archive, "Append", entry, now); + } + internal static void Run() { var assembly = typeof(KillerScan.App).Assembly; @@ -63,6 +82,57 @@ void Fails(Action action, string message) var freshReload = Archive("new.json"); Load(freshReload, now); Require(Entries(freshReload).Count == 100 && Mode(freshReload) == "Count", "Default policy and history survive reload."); + var writerA = Archive("concurrent.json"); + var writerB = Archive("concurrent.json"); + Load(writerA, now); Load(writerB, now); + Append(writerA, Entry("writer-A", now), now); + Append(writerB, Entry("writer-B", now.AddMinutes(1)), now); + var merged = Archive("concurrent.json"); Load(merged, now); + Require(Entries(merged).Count == 2 && + (string)Property(Entries(merged)[0]!, "Target")! == "writer-A" && + (string)Property(Entries(merged)[1]!, "Target")! == "writer-B", + "Stale archive instances preserve both committed scans."); + + string gateName = "Local\\KillerScan.HistoryTest." + Guid.NewGuid().ToString("N"); + using (var start = new EventWaitHandle(false, EventResetMode.ManualReset, gateName)) + using (var readyA = new EventWaitHandle(false, EventResetMode.ManualReset, gateName + ".A")) + using (var readyB = new EventWaitHandle(false, EventResetMode.ManualReset, gateName + ".B")) + { + Process Writer(string target) => Process.Start(new ProcessStartInfo(typeof(HistoryRetentionTests).Assembly.Location, + "--history-writer \"" + Path.Combine(scratch, "processes.json") + "\" " + target + " " + gateName) + { UseShellExecute = false, CreateNoWindow = true })!; + using var processA = Writer("A"); + using var processB = Writer("B"); + try + { + Require(readyA.WaitOne(15000) && readyB.WaitOne(15000), "Two separate processes load the same stale archive."); + start.Set(); + Require(processA.WaitForExit(15000) && processB.WaitForExit(15000) && processA.ExitCode == 0 && processB.ExitCode == 0, + "Concurrent process writers finish successfully."); + } + finally + { + start.Set(); + if (!processA.HasExited) { processA.Kill(); processA.WaitForExit(); } + if (!processB.HasExited) { processB.Kill(); processB.WaitForExit(); } + } + } + var processes = Archive("processes.json"); Load(processes, now); + Require(Entries(processes).Cast().Select(e => (string)Property(e, "Target")!).OrderBy(t => t).SequenceEqual(new[] { "A", "B" }), + "Both separate-process concurrent appends survive reload."); + + Require(Apply(writerA, Policy("SaveAll"), _ => true), "A stale retention editor preserves concurrent entries."); + Append(writerB, Entry("after-policy", now.AddMinutes(2)), now); + Load(merged, now); + Require(Entries(merged).Count == 3 && Mode(merged) == "SaveAll", "A stale writer honors the latest committed retention policy."); + byte[] beforeDenied = File.ReadAllBytes(Path.Combine(scratch, "concurrent.json")); + using (var denied = new FileStream(Path.Combine(scratch, "concurrent.json"), FileMode.Open, FileAccess.Read, FileShare.None)) + Append(writerB, Entry("retry-me", now.AddMinutes(3)), now); + Require(File.ReadAllBytes(Path.Combine(scratch, "concurrent.json")).SequenceEqual(beforeDenied), "Denied access preserves prior archive bytes."); + Append(writerB, Entry("after-retry", now.AddMinutes(4)), now); + Load(merged, now); + Require(Entries(merged).Count == 5, "A later successful append retries unsaved session entries without losing committed scans."); + var legacy = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(entryType))!; for (int i = 0; i < 150; i++) legacy.Add(Entry("legacy-" + i, now.AddDays(i - 150))); var deviceType = assembly.GetType("KillerScan.Services.HistoricalDevice", true)!; diff --git a/tests/SpeedTest.Tests/Program.cs b/tests/SpeedTest.Tests/Program.cs index 4207ad10..07f7075c 100644 --- a/tests/SpeedTest.Tests/Program.cs +++ b/tests/SpeedTest.Tests/Program.cs @@ -29,6 +29,8 @@ private static async Task Main(string[] args) { try { + if (args.SequenceEqual(new[] { "--history-retention" })) { HistoryRetentionTests.Run(); return 0; } + if (args.Length == 4 && args[0] == "--history-writer") { HistoryRetentionTests.WriteConcurrent(args[1], args[2], args[3]); return 0; } if (args.SequenceEqual(new[] { "--scan-presentation" })) { await ScanPresentation(); return 0; } if (args.SequenceEqual(new[] { "--terminal" })) { await View(); return 0; } if (args.SequenceEqual(new[] { "--menu-separators" })) { await MenuSeparatorThemeTests.Run(); return 0; } From c7f331a17e78c0f536f830ba629eaf23c41d306e Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:15:40 -0700 Subject: [PATCH 006/333] v1.8.4: Update app and engine JSON dependency to 10.0.13 --- CHANGELOG.md | 3 +++ Engine/KillerScan.Engine.csproj | 2 +- KillerScan.csproj | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca3783a1..c94d622e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ## [1.8.4] - Unreleased +### Changed +- Updated the app and engine JSON dependency to 10.0.13. + ### Fixed - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. - Window resize minimums stay within the current monitor's work area on small or scaled displays. diff --git a/Engine/KillerScan.Engine.csproj b/Engine/KillerScan.Engine.csproj index cfdd5d45..99bc9898 100644 --- a/Engine/KillerScan.Engine.csproj +++ b/Engine/KillerScan.Engine.csproj @@ -54,7 +54,7 @@ is compiled or shipped. --> - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/KillerScan.csproj b/KillerScan.csproj index b9a4be6c..17d2b2be 100644 --- a/KillerScan.csproj +++ b/KillerScan.csproj @@ -56,7 +56,7 @@ and output entirely. Same fix as KillerNotes. --> - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 3eed18140ce3da2b5e2d83f5a13131962ba90e13 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:18:03 -0700 Subject: [PATCH 007/333] v1.8.4: Verify executable staging and preserve recovery copies --- CHANGELOG.md | 1 + Services/ExecutableDeployment.cs | 95 ++++++++++++++-- .../ExecutableDeploymentTests.cs | 105 ++++++++++++++++++ tests/SpeedTest.Tests/Program.cs | 4 + 4 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 tests/SpeedTest.Tests/ExecutableDeploymentTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c94d622e..bb036be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ### Fixed - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. +- Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. - The shortcuts card shows shadows at its edges when more content is out of view. diff --git a/Services/ExecutableDeployment.cs b/Services/ExecutableDeployment.cs index 71dacb2d..03a1bb64 100644 --- a/Services/ExecutableDeployment.cs +++ b/Services/ExecutableDeployment.cs @@ -1,33 +1,112 @@ using System.IO; +using System.Security.Cryptography; namespace KillerScan.Services { internal static class ExecutableDeployment { + internal sealed class FileOperations + { + internal Action Copy = (input, output) => input.CopyTo(output); + internal Action Replace = File.Replace; + internal Action Move = File.Move; + internal Action Delete = File.Delete; + } + internal static void Install(string source, string destination) + => Install(source, destination, new FileOperations()); + + internal static void Install(string source, string destination, FileOperations files) { source = Path.GetFullPath(source); destination = Path.GetFullPath(destination); if (string.Equals(source, destination, StringComparison.OrdinalIgnoreCase)) return; Directory.CreateDirectory(Path.GetDirectoryName(destination)!); string staged = destination + "." + Guid.NewGuid().ToString("N") + ".new"; + bool retainStaged = false; try { using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var output = new FileStream(staged, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { - input.CopyTo(output); - output.Flush(true); + byte[] expected; + using (var hash = SHA256.Create()) expected = hash.ComputeHash(input); + input.Position = 0; + using (var output = new FileStream(staged, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + files.Copy(input, output); + output.Flush(true); + } + using (var copy = new FileStream(staged, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var hash = SHA256.Create()) + { + byte[] actual = hash.ComputeHash(copy); + if (copy.Length != input.Length || !System.Linq.Enumerable.SequenceEqual(expected, actual)) + throw new InvalidDataException("The staged executable does not match the complete source."); + } } - // Replace only after the complete payload is on the destination volume. - // A failed copy or a locked target leaves the installed executable intact. - if (File.Exists(destination)) File.Replace(staged, destination, null); - else File.Move(staged, destination); + Publish(staged, destination, files, ref retainStaged); } finally { - try { if (File.Exists(staged)) File.Delete(staged); } catch { } + if (!retainStaged) TryDelete(staged, files); + } + } + + private static void Publish(string staged, string destination, FileOperations files, ref bool retainStaged) + { + string backup = destination + "." + Guid.NewGuid().ToString("N") + ".bak"; + try + { + if (File.Exists(destination)) + { + try + { + files.Replace(staged, destination, backup); + TryDelete(backup, files); + return; + } + catch (IOException) { retainStaged = File.Exists(backup) || !File.Exists(destination); } + catch (PlatformNotSupportedException) { } + } + if (File.Exists(destination)) + { + if (File.Exists(backup)) { retainStaged = true; throw new IOException("The replacement left both destination and backup files."); } + files.Move(destination, backup); + retainStaged = true; + } + files.Move(staged, destination); + retainStaged = false; + TryDelete(backup, files); + } + catch (Exception publicationError) + { + if (!File.Exists(destination)) + { + retainStaged = true; + string recovery = File.Exists(backup) ? backup : staged; + try + { + string restored = destination + "." + Guid.NewGuid().ToString("N") + ".restore"; + File.Copy(recovery, restored); + files.Move(restored, destination); + if (recovery == staged) { retainStaged = false; return; } + } + catch (Exception recoveryError) + { + throw RecoveryError(staged, backup, new AggregateException(publicationError, recoveryError)); + } + } + if (retainStaged) throw RecoveryError(staged, backup, publicationError); + throw; } } + + private static IOException RecoveryError(string staged, string backup, Exception error) + => new IOException("The executable could not be published. Recovery files were kept. Staged: " + staged + ". Backup: " + backup, error); + + private static void TryDelete(string path, FileOperations files) + { + try { files.Delete(path); } catch { } + } } } diff --git a/tests/SpeedTest.Tests/ExecutableDeploymentTests.cs b/tests/SpeedTest.Tests/ExecutableDeploymentTests.cs new file mode 100644 index 00000000..faccfb92 --- /dev/null +++ b/tests/SpeedTest.Tests/ExecutableDeploymentTests.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using KillerScan.Services; + +internal static class ExecutableDeploymentTests +{ + public static Task Run() + => RunCase(0); + + public static Task RunPartial(int nativeError) + => RunCase(nativeError); + + private static Task RunCase(int nativeErrorOnly) + { + string directory = Path.Combine(Path.GetTempPath(), "KillerScanDeployment-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + string source = Path.Combine(directory, "download.exe"), target = Path.Combine(directory, "installed.exe"); + byte[] incoming = Enumerable.Range(0, 10000).Select(n => (byte)n).ToArray(); + byte[] original = new byte[] { 10, 20, 30, 40 }; + File.WriteAllBytes(source, incoming); + try + { + if (nativeErrorOnly == 0) + { + foreach (bool existing in new[] { false, true }) + { + if (existing) File.WriteAllBytes(target, original); else if (File.Exists(target)) File.Delete(target); + ExecutableDeployment.Install(source, target); + Require(File.ReadAllBytes(target).SequenceEqual(incoming), "Complete executable publication lost bytes."); + } + File.WriteAllBytes(target, original); + bool failed = false; + try { ExecutableDeployment.Install(source, target, new ExecutableDeployment.FileOperations { Copy = (input, output) => output.WriteByte(1) }); } + catch (Exception error) when (error is IOException || error is InvalidDataException) { failed = true; } + Require(failed && File.ReadAllBytes(target).SequenceEqual(original), "Incomplete staging must fail verification before publication."); + + failed = false; + try + { + ExecutableDeployment.Install(source, target, new ExecutableDeployment.FileOperations + { + Copy = (input, output) => { byte[] changed = (byte[])incoming.Clone(); changed[0] ^= 1; output.Write(changed, 0, changed.Length); } + }); + } + catch (InvalidDataException) { failed = true; } + Require(failed && File.ReadAllBytes(target).SequenceEqual(original), "Same-length staging corruption must fail its hash check."); + + failed = false; + try { ExecutableDeployment.Install(source, target, new ExecutableDeployment.FileOperations { Copy = (input, output) => { output.WriteByte(1); throw new IOException("Partial copy."); } }); } + catch (IOException) { failed = true; } + Require(failed && File.ReadAllBytes(target).SequenceEqual(original), "A partial staging write changed the installed executable."); + using (new FileStream(target, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + failed = false; + try { ExecutableDeployment.Install(source, target); } + catch (Exception error) when (error is IOException || error is UnauthorizedAccessException) { failed = true; } + Require(failed, "A locked executable must not report a successful install."); + } + Require(File.ReadAllBytes(target).SequenceEqual(original), "A locked executable was changed."); + Require(Directory.GetFiles(directory).Length == 2, "Failed staging and locked targets must clean unused staging."); + } + + foreach (int nativeError in new[] { 1176, 1177 }.Where(value => nativeErrorOnly == 0 || value == nativeErrorOnly)) + { + File.WriteAllBytes(target, original); + string? staged = null, saved = null; + var files = new ExecutableDeployment.FileOperations + { + Replace = (temporary, destination, backup) => + { + staged = temporary; + if (nativeError == 1177) { saved = backup ?? destination + ".native-backup"; File.Move(destination, saved); } + else File.Delete(destination); + throw new IOException("Partial replacement.", unchecked((int)(0x80070000u + nativeError))); + }, + Move = (temporary, destination) => throw new IOException("Publication and restoration are unavailable.") + }; + Exception? error = null; + try { ExecutableDeployment.Install(source, target, files); } + catch (IOException problem) { error = problem; } + Require(error != null && File.Exists(staged) && File.ReadAllBytes(staged!).SequenceEqual(incoming), "Partial replacement discarded the complete staged executable."); + Require(error!.Message.Contains(staged!), "Publication errors must name the retained complete staging path."); + if (nativeError == 1177) Require(File.ReadAllBytes(saved!).SequenceEqual(original), "Partial replacement discarded the original backup."); + foreach (string file in Directory.GetFiles(directory).Where(p => p != source)) File.Delete(file); + } + + if (nativeErrorOnly == 0) + { + File.WriteAllBytes(target, original); + ExecutableDeployment.Install(source, target, new ExecutableDeployment.FileOperations { Delete = file => throw new IOException("Cleanup is unavailable.") }); + Require(File.ReadAllBytes(target).SequenceEqual(incoming), "Cleanup failure must not report a failed completed install."); + } + Console.WriteLine("PASS executable deployment checks, selected partial failure: " + nativeErrorOnly); + } + finally { Directory.Delete(directory, true); } + return Task.CompletedTask; + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } +} diff --git a/tests/SpeedTest.Tests/Program.cs b/tests/SpeedTest.Tests/Program.cs index 07f7075c..54e5ebaf 100644 --- a/tests/SpeedTest.Tests/Program.cs +++ b/tests/SpeedTest.Tests/Program.cs @@ -45,6 +45,9 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--csv-export" })) { await CsvExportTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--report-sort" })) { await ReportSortTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--command-line-path" })) { await CommandLinePathTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--executable-deployment" })) { await ExecutableDeploymentTests.Run(); return 0; } + if (args.Length == 2 && args[0] == "--executable-deployment" && int.TryParse(args[1], out int publicationError) + && (publicationError == 1176 || publicationError == 1177)) { await ExecutableDeploymentTests.RunPartial(publicationError); return 0; } if (args.SequenceEqual(new[] { "--report-logo" })) { await ReportLogoTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--outline-hover-ui" })) { await OutlineHoverThemeTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--radio-center-ui" })) { await RadioCenterUiTests.Run(); return 0; } @@ -57,6 +60,7 @@ private static async Task Main(string[] args) await Run("CSV exports keep spreadsheet formulas inert", CsvExportTests.Run); await Run("HTML report columns sort from the keyboard and expose aria-sort", ReportSortTests.Run); await Run("PATH registration adds and removes only the install folder", CommandLinePathTests.Run); + await Run("Executable deployment verifies staging and preserves recovery files", ExecutableDeploymentTests.Run); await Run("HTML report logo returns after a theme without one", ReportLogoTests.Run); await Run("Outline button hover text stays readable in every accent", OutlineHoverThemeTests.RunIsolated); await Run("Radio and checkbox indicators sit on the middle of their labels", RadioCenterUiTests.RunIsolated); From 1a721df65ba172d15f60e903053351867d32aab1 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:23:11 -0700 Subject: [PATCH 008/333] v1.8.4: Preserve preference files and report retryable save failures --- CHANGELOG.md | 1 + Controls/PersistenceFeedback.cs | 24 ++++ Controls/ScanWorkspace/DeviceActions.cs | 10 +- Controls/ScanWorkspace/Scanning.cs | 7 +- Services/DeviceLogins.cs | 42 +++--- Services/DeviceOverrides.cs | 44 +++--- Services/DevicePreferences.cs | 60 ++++----- Services/HistoryArchive.cs | 34 ++--- Services/JsonPreferenceFile.cs | 110 +++++++++++++++ Services/ScanHistory.cs | 14 +- Services/ScanProfiles.cs | 59 ++++---- Shell/Profiles.cs | 7 +- Shell/TerminalScan.cs | 3 +- Shell/Workspace.cs | 3 +- Strings/bn.xaml | 1 + Strings/cs-CZ.xaml | 1 + Strings/de-DE.xaml | 1 + Strings/en-US.xaml | 1 + Strings/es.xaml | 1 + Strings/fr-FR.xaml | 1 + Strings/hu-HU.xaml | 1 + Strings/it-IT.xaml | 1 + Strings/ja-JP.xaml | 1 + Strings/kk-KZ.xaml | 1 + Strings/nb-NO.xaml | 1 + Strings/pl-PL.xaml | 1 + Strings/pt-BR.xaml | 1 + Strings/ru-RU.xaml | 1 + Strings/tr-TR.xaml | 1 + Strings/uk-UA.xaml | 1 + Strings/vi-VN.xaml | 1 + Strings/zh-CN.xaml | 1 + Strings/zh-TW.xaml | 1 + .../SpeedTest.Tests/HistoryRetentionTests.cs | 3 +- .../PreferencePersistenceTests.cs | 127 ++++++++++++++++++ tests/SpeedTest.Tests/Program.cs | 1 + 36 files changed, 411 insertions(+), 157 deletions(-) create mode 100644 Controls/PersistenceFeedback.cs create mode 100644 Services/JsonPreferenceFile.cs create mode 100644 tests/SpeedTest.Tests/PreferencePersistenceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index bb036be7..549edf3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Updated the app and engine JSON dependency to 10.0.13. ### Fixed +- Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. diff --git a/Controls/PersistenceFeedback.cs b/Controls/PersistenceFeedback.cs new file mode 100644 index 00000000..8045a355 --- /dev/null +++ b/Controls/PersistenceFeedback.cs @@ -0,0 +1,24 @@ +using System.Windows; +using KillerScan.Services; + +namespace KillerScan.Controls +{ + internal static class PersistenceFeedback + { + internal static bool Check(bool saved, Func retry, Func error) + { + while (!saved) + { + string format = (string)Application.Current.FindResource("Str_SaveFailed"); + string message = string.Format(format, error()); + var owner = Application.Current.MainWindow; + var answer = owner == null + ? MessageBox.Show(message, AppInfo.DisplayName, MessageBoxButton.YesNo, MessageBoxImage.Warning) + : MessageBox.Show(owner, message, AppInfo.DisplayName, MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (answer != MessageBoxResult.Yes) return false; + saved = retry(); + } + return true; + } + } +} diff --git a/Controls/ScanWorkspace/DeviceActions.cs b/Controls/ScanWorkspace/DeviceActions.cs index b9e17ad4..50d3db34 100644 --- a/Controls/ScanWorkspace/DeviceActions.cs +++ b/Controls/ScanWorkspace/DeviceActions.cs @@ -86,7 +86,7 @@ private void SetType_Click(object sender, RoutedEventArgs e) var d = GetSelectedDevice(); if (d == null || string.IsNullOrEmpty(d.MacAddress)) return; if (sender is MenuItem mi && mi.Tag is string type) { - DeviceOverrides.Set(d.MacAddress, type); + PersistenceFeedback.Check(DeviceOverrides.Set(d.MacAddress, type), DeviceOverrides.Save, () => DeviceOverrides.SaveError); d.DeviceType = type; _filteredView?.Refresh(); if (_showTopology) RefreshTopology(); @@ -97,7 +97,7 @@ private void SetType_Click(object sender, RoutedEventArgs e) private void ClearOverride_Click(object sender, RoutedEventArgs e) { var d = GetSelectedDevice(); if (d == null || string.IsNullOrEmpty(d.MacAddress)) return; - DeviceOverrides.Set(d.MacAddress, null); + PersistenceFeedback.Check(DeviceOverrides.Set(d.MacAddress, null), DeviceOverrides.Save, () => DeviceOverrides.SaveError); d.DeviceType = NetworkScanner.ClassifyDevice(d); _filteredView?.Refresh(); if (_showTopology) RefreshTopology(); @@ -116,7 +116,7 @@ private void RenameDevice_Click(object sender, RoutedEventArgs e) Loc("Str_Btn_Cancel")) { Owner = Window.GetWindow(this) }; dlg.ShowDialog(); if (!dlg.Confirmed) return; - DevicePreferences.SetName(d, dlg.Value); + PersistenceFeedback.Check(DevicePreferences.SetName(d, dlg.Value), DevicePreferences.Retry, () => DevicePreferences.SaveError); if (!string.IsNullOrWhiteSpace(dlg.Value)) d.Hostname = dlg.Value.Trim(); _filteredView?.Refresh(); if (_showTopology) RefreshTopology(); @@ -125,7 +125,7 @@ private void RenameDevice_Click(object sender, RoutedEventArgs e) private void ClearDeviceName_Click(object sender, RoutedEventArgs e) { var d = GetSelectedDevice(); if (d == null) return; - DevicePreferences.SetName(d, null); + PersistenceFeedback.Check(DevicePreferences.SetName(d, null), DevicePreferences.Retry, () => DevicePreferences.SaveError); d.Hostname = string.IsNullOrWhiteSpace(d.NetbiosName) ? string.Empty : d.NetbiosName; _filteredView?.Refresh(); if (_showTopology) RefreshTopology(); @@ -134,7 +134,7 @@ private void ClearDeviceName_Click(object sender, RoutedEventArgs e) private void TrustDevice_Click(object sender, RoutedEventArgs e) { var d = GetSelectedDevice(); if (d == null) return; - DevicePreferences.SetTrusted(d, !DevicePreferences.IsTrusted(d)); + PersistenceFeedback.Check(DevicePreferences.SetTrusted(d, !DevicePreferences.IsTrusted(d)), DevicePreferences.Retry, () => DevicePreferences.SaveError); ((System.Windows.Shapes.Ellipse)TrustDeviceMenuItem.Icon).Fill = (System.Windows.Media.Brush)new TrustMarkConverter().Convert( d, typeof(System.Windows.Media.Brush), null!, System.Globalization.CultureInfo.CurrentCulture); // Trust is not a property of the device, so nothing notifies the row that its gutter diff --git a/Controls/ScanWorkspace/Scanning.cs b/Controls/ScanWorkspace/Scanning.cs index c10a4f73..0c4395b3 100644 --- a/Controls/ScanWorkspace/Scanning.cs +++ b/Controls/ScanWorkspace/Scanning.cs @@ -76,13 +76,14 @@ private async void ScanBtn_Click(object sender, RoutedEventArgs e) completed = !cts.IsCancellationRequested && !_disposed; if (completed && _active.Devices.Count > 0) { - ScanHistory.Record(_active.ScannedSubnet, _active.Devices); + var recorded = ScanHistory.Record(_active.ScannedSubnet, _active.Devices); // Tell the shell so the history sidebar picks up the new entry rather than // showing a stale list until it is next reopened. - HistoryRecorded?.Invoke(this, EventArgs.Empty); + if (PersistenceFeedback.Check(recorded.Saved, ScanHistory.Retry, () => ScanHistory.SaveError)) + HistoryRecorded?.Invoke(this, EventArgs.Empty); if (!DevicePreferences.HasTrustedDevices) { - DevicePreferences.TrustAll(_active.Devices); + PersistenceFeedback.Check(DevicePreferences.TrustAll(_active.Devices), DevicePreferences.Retry, () => DevicePreferences.SaveError); StatusText.Text = string.Format(Loc("Str_St_TrustedBaseline"), _active.Devices.Count); } else diff --git a/Services/DeviceLogins.cs b/Services/DeviceLogins.cs index 89090553..dd7fa788 100644 --- a/Services/DeviceLogins.cs +++ b/Services/DeviceLogins.cs @@ -26,33 +26,19 @@ internal static class DeviceLogins Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "KillerScan", "logins.json"); - private static Dictionary _logins = new(StringComparer.OrdinalIgnoreCase); + private static JsonPreferenceFile> _store = CreateStore(); + private static Dictionary _logins => _store.Value; + public static string? SaveError => _store.Error; + private static JsonPreferenceFile> CreateStore() => new(FilePath, + () => new(StringComparer.OrdinalIgnoreCase), value => new(value, StringComparer.OrdinalIgnoreCase)); public static void Load() { - try - { - if (!File.Exists(FilePath)) return; - string json = File.ReadAllText(FilePath); - var loaded = JsonSerializer.Deserialize>(json); - if (loaded != null) - _logins = new Dictionary(loaded, StringComparer.OrdinalIgnoreCase); - } - catch { } + _store = CreateStore(); + _store.Load(); } - public static void Save() - { - try - { - var dir = Path.GetDirectoryName(FilePath)!; - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - var options = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(FilePath, JsonSerializer.Serialize(_logins, options)); - } - catch { } - } + public static bool Save() => _store.Save(); /// MAC when there is one, otherwise the IP. Empty when neither is known, which the /// callers treat as "do not remember this one". @@ -72,13 +58,15 @@ private static string KeyFor(string? mac, string? ip) => /// Remembers a username for the device. An empty or null username REMOVES the entry, /// so clearing the box in the prompt goes back to plain `ssh <ip>` rather than storing a /// blank that would look like a remembered answer. - public static void Set(string? mac, string? ip, string? user) + public static bool Set(string? mac, string? ip, string? user) { string key = KeyFor(mac, ip); - if (key.Length == 0) return; - if (string.IsNullOrWhiteSpace(user)) _logins.Remove(key); - else _logins[key] = user!.Trim(); - Save(); + if (key.Length == 0) return true; + return _store.Change(values => + { + if (string.IsNullOrWhiteSpace(user)) values.Remove(key); + else values[key] = user!.Trim(); + }); } } } diff --git a/Services/DeviceOverrides.cs b/Services/DeviceOverrides.cs index 4d9bf33f..3d883be9 100644 --- a/Services/DeviceOverrides.cs +++ b/Services/DeviceOverrides.cs @@ -12,46 +12,32 @@ public static class DeviceOverrides Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "KillerScan", "overrides.json"); - private static Dictionary _overrides = new(StringComparer.OrdinalIgnoreCase); + private static JsonPreferenceFile> _store = CreateStore(); + private static Dictionary _overrides => _store.Value; + public static string? SaveError => _store.Error; + private static JsonPreferenceFile> CreateStore() => new(FilePath, + () => new(StringComparer.OrdinalIgnoreCase), value => new(value, StringComparer.OrdinalIgnoreCase)); public static void Load() { - try - { - if (!File.Exists(FilePath)) return; - string json = File.ReadAllText(FilePath); - var loaded = JsonSerializer.Deserialize>(json); - if (loaded != null) - _overrides = new Dictionary(loaded, StringComparer.OrdinalIgnoreCase); - } - catch { } + _store = CreateStore(); + _store.Load(); } - public static void Save() - { - try - { - var dir = Path.GetDirectoryName(FilePath)!; - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - var options = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(FilePath, JsonSerializer.Serialize(_overrides, options)); - } - catch { } - } + public static bool Save() => _store.Save(); /// /// Set a manual override for a device by MAC address. Pass null to remove. /// - public static void Set(string mac, string? deviceType) + public static bool Set(string mac, string? deviceType) { - if (string.IsNullOrEmpty(mac)) return; + if (string.IsNullOrEmpty(mac)) return true; string key = mac.ToUpperInvariant(); - if (deviceType == null) - _overrides.Remove(key); - else - _overrides[key] = deviceType; - Save(); + return _store.Change(values => + { + if (deviceType == null) values.Remove(key); + else values[key] = deviceType; + }); } /// diff --git a/Services/DevicePreferences.cs b/Services/DevicePreferences.cs index 282ebd2d..5b5a1c54 100644 --- a/Services/DevicePreferences.cs +++ b/Services/DevicePreferences.cs @@ -9,8 +9,11 @@ internal static class DevicePreferences private static readonly string FilePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "KillerScan", "devices.json"); - private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - private static DevicePreferenceData _data = new(); + private static JsonPreferenceFile _store = CreateStore(); + private static DevicePreferenceData _data => _store.Value; + public static string? SaveError => _store.Error; + private static JsonPreferenceFile CreateStore() => new(FilePath, + () => new(), value => { value.Normalize(); return value; }); public static bool HasTrustedDevices => _data.Trusted.Count > 0; @@ -18,17 +21,8 @@ public static void Load() { // Demo mode starts from nothing and stays in memory, so the fabricated network cannot // trust, rename, or otherwise disturb the real devices on the machine it runs on. - if (DemoData.Enabled) { _data = new DevicePreferenceData(); return; } - try - { - if (!File.Exists(FilePath)) return; - _data = JsonSerializer.Deserialize(File.ReadAllText(FilePath)) ?? new(); - _data.Normalize(); - } - catch - { - _data = new DevicePreferenceData(); - } + _store = CreateStore(); + if (!DemoData.Enabled) _store.Load(); } public static void Apply(NetworkDevice device) @@ -41,41 +35,39 @@ public static void Apply(NetworkDevice device) public static string? GetName(NetworkDevice device) => _data.Names.TryGetValue(DeviceIdentity.For(device), out string? name) ? name : null; - public static void SetName(NetworkDevice device, string? name) + public static bool SetName(NetworkDevice device, string? name) { string identity = DeviceIdentity.For(device); - if (string.IsNullOrWhiteSpace(name)) _data.Names.Remove(identity); - else _data.Names[identity] = name!.Trim(); - Save(); + return _store.Change(value => + { + if (string.IsNullOrWhiteSpace(name)) value.Names.Remove(identity); + else value.Names[identity] = name!.Trim(); + }, DemoData.Enabled); } public static bool IsTrusted(NetworkDevice device) => _data.Trusted.Contains(DeviceIdentity.For(device)); - public static void SetTrusted(NetworkDevice device, bool trusted) + public static bool SetTrusted(NetworkDevice device, bool trusted) { string identity = DeviceIdentity.For(device); - if (trusted) _data.Trusted.Add(identity); - else _data.Trusted.Remove(identity); - Save(); - } - - public static void TrustAll(IEnumerable devices) - { - foreach (var device in devices) _data.Trusted.Add(DeviceIdentity.For(device)); - Save(); + return _store.Change(value => + { + if (trusted) value.Trusted.Add(identity); + else value.Trusted.Remove(identity); + }, DemoData.Enabled); } - private static void Save() + public static bool TrustAll(IEnumerable devices) { - if (DemoData.Enabled) return; - try + var identities = devices.Select(DeviceIdentity.For).ToArray(); + return _store.Change(value => { - Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); - File.WriteAllText(FilePath, JsonSerializer.Serialize(_data, JsonOptions)); - } - catch { } + foreach (string identity in identities) value.Trusted.Add(identity); + }, DemoData.Enabled); } + public static bool Retry() => DemoData.Enabled || _store.Save(); + private sealed class DevicePreferenceData { public Dictionary Names { get; set; } = new(StringComparer.OrdinalIgnoreCase); diff --git a/Services/HistoryArchive.cs b/Services/HistoryArchive.cs index 3f8f4517..6e6606b2 100644 --- a/Services/HistoryArchive.cs +++ b/Services/HistoryArchive.cs @@ -17,6 +17,7 @@ internal sealed class HistoryArchive private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; internal List Entries { get; private set; } = []; internal HistoryRetention Policy { get; private set; } = new(HistoryRetentionMode.Count); + internal string? SaveError { get; private set; } internal HistoryArchive(string path) => _path = Path.GetFullPath(path); @@ -30,7 +31,7 @@ internal void Load(DateTimeOffset now) if (_readable && Policy.Mode == HistoryRetentionMode.Time) { var kept = Policy.Retained(Entries, now); - if (kept.Count != Entries.Count) { Persist(kept, Policy); Entries = kept; } + if (kept.Count != Entries.Count) { Persist(kept, Policy); Entries = kept; _pending.Clear(); } } return true; }); @@ -110,23 +111,34 @@ internal bool Apply(HistoryRetention policy, DateTimeOffset now, Func } } - internal void Append(ScanHistoryEntry entry, DateTimeOffset now) + internal bool Append(ScanHistoryEntry entry, DateTimeOffset now) { _pending.Add(entry); + return Retry(now); + } + + internal bool Retry(DateTimeOffset now) + { + var session = Entries; try { - Locked(() => + return Locked(() => { ReadCurrent(); EnsureReadable(); var kept = Policy.Retained(Entries, now); Persist(kept, Policy); Entries = kept; _pending.Clear(); + SaveError = null; return true; }); } - catch (IOException) { Entries = Policy.Retained(Entries.Concat(_pending).Distinct(), now); } - catch (UnauthorizedAccessException) { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Entries = Policy.Retained(session.Concat(_pending).Distinct(), now); + SaveError = ex.Message; + return false; + } } private void EnsureReadable() @@ -153,16 +165,8 @@ private T Locked(Func operation) private void Persist(List entries, HistoryRetention policy) { EnsureReadable(); - string directory = Path.GetDirectoryName(_path)!; - Directory.CreateDirectory(directory); - string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp"; - try - { - File.WriteAllText(temporary, JsonSerializer.Serialize(new HistoryFile { Entries = entries, Policy = policy }, JsonOptions)); - if (File.Exists(_path)) File.Replace(temporary, _path, null); - else File.Move(temporary, _path); - } - finally { if (File.Exists(temporary)) File.Delete(temporary); } + JsonPreferenceFile.Publish(_path, Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(new HistoryFile { Entries = entries, Policy = policy }, JsonOptions))); } private sealed class HistoryFile diff --git a/Services/JsonPreferenceFile.cs b/Services/JsonPreferenceFile.cs new file mode 100644 index 00000000..93471393 --- /dev/null +++ b/Services/JsonPreferenceFile.cs @@ -0,0 +1,110 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; + +namespace KillerScan.Services +{ + // Mutations are replayed over the latest committed file, never over another window's stale copy. + internal sealed class JsonPreferenceFile where T : class + { + private readonly string _path; + private readonly Func _create; + private readonly Func _normalize; + private readonly Action? _write; + private readonly List> _pending = []; + internal T Value { get; private set; } + internal string? Error { get; private set; } + + internal JsonPreferenceFile(string path, Func create, Func normalize, Action? write = null) + { + _path = Path.GetFullPath(path); _create = create; _normalize = normalize; _write = write; Value = create(); + } + + internal void Load() + { + try { Value = Read(); Error = null; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or ArgumentException) + { Value = _create(); Error = ex.Message; } + } + + internal bool Change(Action mutation, bool memoryOnly = false) + { + mutation(Value); + if (memoryOnly) return true; + _pending.Add(mutation); + return Save(); + } + + internal bool Save() + { + try + { + using var hash = SHA256.Create(); + string key = BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(_path.ToUpperInvariant()))).Replace("-", ""); + using var mutex = new Mutex(false, "Global\\KillerScan.Preferences." + key); + bool owned = false; + try + { + try { owned = mutex.WaitOne(TimeSpan.FromSeconds(2)); } + catch (AbandonedMutexException) { owned = true; } + if (!owned) throw new IOException("The preferences file is busy. Try again."); + var latest = Read(); + foreach (var mutation in _pending) mutation(latest); + Publish(_path, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(latest, new JsonSerializerOptions { WriteIndented = true })), _write); + Value = latest; _pending.Clear(); Error = null; + return true; + } + finally { if (owned) mutex.ReleaseMutex(); } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or ArgumentException) + { Error = ex.Message; return false; } + } + + private T Read() + { + try + { + return _normalize(JsonSerializer.Deserialize(File.ReadAllText(_path)) + ?? throw new InvalidDataException("The preferences file contains no saved data.")); + } + catch (FileNotFoundException) { return _create(); } + catch (DirectoryNotFoundException) { return _create(); } + } + + internal static void Publish(string path, byte[] bytes, Action? write = null) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + string backup = temporary + ".original"; + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + if (write == null) stream.Write(bytes, 0, bytes.Length); + else write(stream, bytes); + stream.Flush(true); + } + if (File.Exists(path)) + { + try { File.Replace(temporary, path, backup); } + catch + { + // A failed native replacement can move the original to its backup name. + if (!File.Exists(path) && File.Exists(backup)) File.Move(backup, path); + throw; + } + TryDelete(backup); + } + else File.Move(temporary, path); + } + finally { TryDelete(temporary); } + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + } +} diff --git a/Services/ScanHistory.cs b/Services/ScanHistory.cs index bd688471..4275a89e 100644 --- a/Services/ScanHistory.cs +++ b/Services/ScanHistory.cs @@ -30,7 +30,8 @@ internal sealed record ScanComparison( ScanHistoryEntry? Previous, IReadOnlyList Added, IReadOnlyList Removed, - IReadOnlyList Changed); + IReadOnlyList Changed, + bool Saved = true); internal static class ScanHistory { @@ -42,6 +43,15 @@ internal static class ScanHistory private static HistoryRetention _demoRetention = new(HistoryRetentionMode.Count); public static IReadOnlyList Entries => _entries; + public static string? SaveError => _archive.SaveError; + + public static bool Retry() + { + if (DemoData.Enabled) return true; + bool saved = _archive.Retry(DateTimeOffset.Now); + _entries = _archive.Entries; + return saved; + } internal static HistoryRetention Retention => DemoData.Enabled ? _demoRetention : _archive.Policy; @@ -105,7 +115,7 @@ public static ScanComparison Record(string target, IEnumerable de if (DemoData.Enabled) _entries = _demoRetention.Retained(_entries.Concat([current]), current.ScannedAt); else { - _archive.Append(current, current.ScannedAt); + comparison = comparison with { Saved = _archive.Append(current, current.ScannedAt) }; _entries = _archive.Entries; } return comparison; diff --git a/Services/ScanProfiles.cs b/Services/ScanProfiles.cs index 30b33faa..0684a6b1 100644 --- a/Services/ScanProfiles.cs +++ b/Services/ScanProfiles.cs @@ -18,8 +18,15 @@ internal static class ScanProfiles private static readonly string FilePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "KillerScan", "profiles.json"); - private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - private static List _items = []; + private static JsonPreferenceFile> _store = CreateStore(); + private static List _items => _store.Value; + public static string? SaveError => _store.Error; + private static JsonPreferenceFile> CreateStore() => new(FilePath, () => [], items => + { + items.RemoveAll(item => item == null); + foreach (var item in items) { item.Name ??= string.Empty; item.Target ??= string.Empty; } + return items; + }); public static IReadOnlyList Items => _items; @@ -27,45 +34,25 @@ public static void Load() { // Demo mode keeps its profiles in memory: a screenshot build must not add fabricated // profiles to whatever the person running it has saved. - if (DemoData.Enabled) { _items = [.. DemoData.Profiles()]; return; } - try - { - if (!File.Exists(FilePath)) return; - _items = JsonSerializer.Deserialize>(File.ReadAllText(FilePath)) ?? []; - _items.RemoveAll(item => item == null); - foreach (var item in _items) - { - item.Name ??= string.Empty; - item.Target ??= string.Empty; - } - } - catch { _items = []; } - } - - public static void Save(ScanProfile profile) - { - _items.RemoveAll(item => string.Equals(item.Name, profile.Name, StringComparison.OrdinalIgnoreCase)); - _items.Add(profile); - Persist(); + _store = CreateStore(); + if (DemoData.Enabled) { _items.AddRange(DemoData.Profiles()); return; } + _store.Load(); } - public static void Delete(ScanProfile profile) + public static bool Save(ScanProfile profile) { - _items.Remove(profile); - Persist(); + var saved = new ScanProfile { Name = profile.Name, Target = profile.Target, DeepScanAfter = profile.DeepScanAfter }; + return _store.Change(items => + { + items.RemoveAll(item => string.Equals(item.Name, saved.Name, StringComparison.OrdinalIgnoreCase)); + items.Add(saved); + }, DemoData.Enabled); } - public static void Update() => Persist(); + public static bool Delete(ScanProfile profile) => _store.Change(items => + items.RemoveAll(item => string.Equals(item.Name, profile.Name, StringComparison.OrdinalIgnoreCase)), DemoData.Enabled); - private static void Persist() - { - if (DemoData.Enabled) return; - try - { - Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); - File.WriteAllText(FilePath, JsonSerializer.Serialize(_items, JsonOptions)); - } - catch { } - } + public static bool Update(ScanProfile profile) => Save(profile); + public static bool Retry() => DemoData.Enabled || _store.Save(); } } diff --git a/Shell/Profiles.cs b/Shell/Profiles.cs index 7940e26d..191e5408 100644 --- a/Shell/Profiles.cs +++ b/Shell/Profiles.cs @@ -44,7 +44,8 @@ private void SaveProfile_Click(object sender, RoutedEventArgs e) Loc("Str_Rename_Save"), Loc("Str_Btn_Cancel")) { Owner = this }; dialog.ShowDialog(); if (!dialog.Confirmed || string.IsNullOrWhiteSpace(dialog.Value)) return; - ScanProfiles.Save(new ScanProfile { Name = dialog.Value, Target = scan.Targets.Trim() }); + PersistenceFeedback.Check(ScanProfiles.Save(new ScanProfile { Name = dialog.Value, Target = scan.Targets.Trim() }), + ScanProfiles.Retry, () => ScanProfiles.SaveError); RefreshProfilesList(); } @@ -75,13 +76,13 @@ private void ProfileDeep_Click(object sender, RoutedEventArgs e) { if (sender is not MenuItem item || ProfileFor(sender) is not { } profile) return; profile.DeepScanAfter = item.IsChecked; - ScanProfiles.Update(); + PersistenceFeedback.Check(ScanProfiles.Update(profile), ScanProfiles.Retry, () => ScanProfiles.SaveError); } private void ProfileDelete_Click(object sender, RoutedEventArgs e) { if (ProfileFor(sender) is not { } profile) return; - ScanProfiles.Delete(profile); + PersistenceFeedback.Check(ScanProfiles.Delete(profile), ScanProfiles.Retry, () => ScanProfiles.SaveError); RefreshProfilesList(); } } diff --git a/Shell/TerminalScan.cs b/Shell/TerminalScan.cs index bccff082..cec436e7 100644 --- a/Shell/TerminalScan.cs +++ b/Shell/TerminalScan.cs @@ -127,7 +127,8 @@ private async void RunTerminalScan(string target) _terminalScanCount = devices.Count; if (!Services.DevicePreferences.HasTrustedDevices) { - Services.DevicePreferences.TrustAll(devices); + Controls.PersistenceFeedback.Check(Services.DevicePreferences.TrustAll(devices), + Services.DevicePreferences.Retry, () => Services.DevicePreferences.SaveError); _terminalStatusKey = "Str_St_TrustedBaseline"; _terminalStatusArgument = devices.Count; } diff --git a/Shell/Workspace.cs b/Shell/Workspace.cs index 8681b0f5..281903cb 100644 --- a/Shell/Workspace.cs +++ b/Shell/Workspace.cs @@ -474,7 +474,8 @@ private void RunWorkspaceDeviceAction(NetworkDevice device, string action, int? { var dialog = new InputDialog(string.Format(Loc("Str_Ssh_Head"), ip), Loc("Str_Ssh_Detail"), Loc("Str_Ssh_User"), user ?? "", Loc("Str_Ssh_Connect"), Loc("Str_Btn_Cancel")) { Owner = this }; dialog.ShowDialog(); if (!dialog.Confirmed) return; - user = dialog.Value; DeviceLogins.Set(device.MacAddress, ip, user); + user = dialog.Value; + PersistenceFeedback.Check(DeviceLogins.Set(device.MacAddress, ip, user), DeviceLogins.Save, () => DeviceLogins.SaveError); } // Invoke the SSH client directly; user input never goes through a command shell. command = "ssh.exe " + (servicePort.HasValue ? "-p " + servicePort.Value + " " : "") diff --git a/Strings/bn.xaml b/Strings/bn.xaml index 9d4fbbf5..911cd156 100644 --- a/Strings/bn.xaml +++ b/Strings/bn.xaml @@ -503,4 +503,5 @@ এক পৃষ্ঠা নিচে এক পৃষ্ঠা বাঁয়ে এক পৃষ্ঠা ডানে + পরিবর্তনগুলো সংরক্ষণ করা যায়নি। এই সেশনে সেগুলো এখনও উপলভ্য আছে। আবার সংরক্ষণ করার চেষ্টা করবেন? {0} diff --git a/Strings/cs-CZ.xaml b/Strings/cs-CZ.xaml index 45424d91..5c8b233b 100644 --- a/Strings/cs-CZ.xaml +++ b/Strings/cs-CZ.xaml @@ -501,4 +501,5 @@ Vaše nastavení nebude odstraněno. O stránku dolů O stránku doleva O stránku doprava + Změny se nepodařilo uložit. V této relaci jsou stále dostupné. Zkusit uložit znovu? {0} diff --git a/Strings/de-DE.xaml b/Strings/de-DE.xaml index 87ee37c6..e9fd9973 100644 --- a/Strings/de-DE.xaml +++ b/Strings/de-DE.xaml @@ -504,4 +504,5 @@ Ihre Einstellungen bleiben erhalten. Seite nach unten Seite nach links Seite nach rechts + Die Änderungen konnten nicht gespeichert werden. Sie bleiben für diese Sitzung verfügbar. Erneut speichern? {0} diff --git a/Strings/en-US.xaml b/Strings/en-US.xaml index 6fa643fe..090e9a24 100644 --- a/Strings/en-US.xaml +++ b/Strings/en-US.xaml @@ -545,4 +545,5 @@ Your settings will not be removed. Page down Page left Page right + Could not save changes. They are still available for this session. Try saving again? {0} diff --git a/Strings/es.xaml b/Strings/es.xaml index 3423a34c..7dad3d3d 100644 --- a/Strings/es.xaml +++ b/Strings/es.xaml @@ -503,4 +503,5 @@ Su configuración no se eliminará. Una página hacia abajo Una página hacia la izquierda Una página hacia la derecha + No se pudieron guardar los cambios. Siguen disponibles durante esta sesión. ¿Intentar guardarlos de nuevo? {0} diff --git a/Strings/fr-FR.xaml b/Strings/fr-FR.xaml index 667e9109..876c76ed 100644 --- a/Strings/fr-FR.xaml +++ b/Strings/fr-FR.xaml @@ -503,4 +503,5 @@ Vos paramètres ne seront pas supprimés. Page vers le bas Page vers la gauche Page vers la droite + Impossible d’enregistrer les modifications. Elles restent disponibles pendant cette session. Réessayer de les enregistrer ? {0} diff --git a/Strings/hu-HU.xaml b/Strings/hu-HU.xaml index e65d7795..b8734bbc 100644 --- a/Strings/hu-HU.xaml +++ b/Strings/hu-HU.xaml @@ -519,4 +519,5 @@ A beállítások nem törlődnek. Egy oldallal lejjebb Egy oldallal balra Egy oldallal jobbra + A módosításokat nem sikerült menteni. Ebben a munkamenetben továbbra is elérhetők. Megpróbálja újra menteni? {0} diff --git a/Strings/it-IT.xaml b/Strings/it-IT.xaml index c6d11b52..9b00c846 100644 --- a/Strings/it-IT.xaml +++ b/Strings/it-IT.xaml @@ -519,4 +519,5 @@ Le tue impostazioni non verranno rimosse. Pagina giù Pagina a sinistra Pagina a destra + Impossibile salvare le modifiche. Restano disponibili per questa sessione. Riprovare a salvarle? {0} diff --git a/Strings/ja-JP.xaml b/Strings/ja-JP.xaml index 3f8e00ba..dab1cdf3 100644 --- a/Strings/ja-JP.xaml +++ b/Strings/ja-JP.xaml @@ -514,4 +514,5 @@ 1 ページ下へ 1 ページ左へ 1 ページ右へ + 変更を保存できませんでした。このセッションでは引き続き利用できます。もう一度保存しますか? {0} diff --git a/Strings/kk-KZ.xaml b/Strings/kk-KZ.xaml index 1335cb85..09970f64 100644 --- a/Strings/kk-KZ.xaml +++ b/Strings/kk-KZ.xaml @@ -545,4 +545,5 @@ Бір бет төмен Бір бет солға Бір бет оңға + Өзгерістерді сақтау мүмкін болмады. Олар осы сеанс кезінде қолжетімді болып қалады. Қайта сақтап көресіз бе? {0} diff --git a/Strings/nb-NO.xaml b/Strings/nb-NO.xaml index 15db472a..48c74f1f 100644 --- a/Strings/nb-NO.xaml +++ b/Strings/nb-NO.xaml @@ -545,4 +545,5 @@ Innstillingene dine fjernes ikke. Side ned Side til venstre Side til høyre + Kunne ikke lagre endringene. De er fortsatt tilgjengelige i denne økten. Prøve å lagre på nytt? {0} diff --git a/Strings/pl-PL.xaml b/Strings/pl-PL.xaml index bd4b688d..d74bfae8 100644 --- a/Strings/pl-PL.xaml +++ b/Strings/pl-PL.xaml @@ -488,4 +488,5 @@ Twoje ustawienia nie zostaną usunięte. Strona w dół Strona w lewo Strona w prawo + Nie udało się zapisać zmian. Nadal są dostępne w tej sesji. Spróbować zapisać ponownie? {0} diff --git a/Strings/pt-BR.xaml b/Strings/pt-BR.xaml index b68dd062..f6d4ec71 100644 --- a/Strings/pt-BR.xaml +++ b/Strings/pt-BR.xaml @@ -545,4 +545,5 @@ Suas configurações não serão removidas. Uma página abaixo Uma página à esquerda Uma página à direita + Não foi possível salvar as alterações. Elas continuam disponíveis nesta sessão. Tentar salvar novamente? {0} diff --git a/Strings/ru-RU.xaml b/Strings/ru-RU.xaml index f1eb7583..b8af0480 100644 --- a/Strings/ru-RU.xaml +++ b/Strings/ru-RU.xaml @@ -529,4 +529,5 @@ На страницу вниз На страницу влево На страницу вправо + Не удалось сохранить изменения. Они остаются доступны в этом сеансе. Попробовать сохранить снова? {0} diff --git a/Strings/tr-TR.xaml b/Strings/tr-TR.xaml index 7bd32dd6..e6dd70c4 100644 --- a/Strings/tr-TR.xaml +++ b/Strings/tr-TR.xaml @@ -503,4 +503,5 @@ Ayarlarınız silinmeyecek. Bir sayfa aşağı Bir sayfa sola Bir sayfa sağa + Değişiklikler kaydedilemedi. Bu oturumda kullanılabilir olmaya devam ederler. Yeniden kaydetmek ister misiniz? {0} diff --git a/Strings/uk-UA.xaml b/Strings/uk-UA.xaml index 9a509fb7..c9833c1e 100644 --- a/Strings/uk-UA.xaml +++ b/Strings/uk-UA.xaml @@ -545,4 +545,5 @@ На сторінку вниз На сторінку ліворуч На сторінку праворуч + Не вдалося зберегти зміни. Вони залишаються доступними в цьому сеансі. Спробувати зберегти ще раз? {0} diff --git a/Strings/vi-VN.xaml b/Strings/vi-VN.xaml index 4b00c83d..2b7edb1c 100644 --- a/Strings/vi-VN.xaml +++ b/Strings/vi-VN.xaml @@ -484,4 +484,5 @@ Cài đặt của bạn sẽ không bị xóa. Xuống một trang Sang trái một trang Sang phải một trang + Không thể lưu thay đổi. Các thay đổi vẫn có sẵn trong phiên này. Thử lưu lại? {0} diff --git a/Strings/zh-CN.xaml b/Strings/zh-CN.xaml index 17c98459..f967afdb 100644 --- a/Strings/zh-CN.xaml +++ b/Strings/zh-CN.xaml @@ -503,4 +503,5 @@ 向下翻页 向左翻页 向右翻页 + 无法保存更改。这些更改在当前会话中仍然可用。是否重新尝试保存? {0} diff --git a/Strings/zh-TW.xaml b/Strings/zh-TW.xaml index c17d7280..7bb3ad30 100644 --- a/Strings/zh-TW.xaml +++ b/Strings/zh-TW.xaml @@ -503,4 +503,5 @@ 向下翻頁 向左翻頁 向右翻頁 + 無法儲存變更。這些變更在目前工作階段中仍可使用。是否重新嘗試儲存? {0} diff --git a/tests/SpeedTest.Tests/HistoryRetentionTests.cs b/tests/SpeedTest.Tests/HistoryRetentionTests.cs index ea03be98..0528499f 100644 --- a/tests/SpeedTest.Tests/HistoryRetentionTests.cs +++ b/tests/SpeedTest.Tests/HistoryRetentionTests.cs @@ -213,7 +213,8 @@ Process Writer(string target) => Process.Start(new ProcessStartInfo(typeof(Histo File.WriteAllText(malformed, "{broken"); var unreadable = Archive("malformed.json"); Load(unreadable, now); Fails(() => Apply(unreadable, Policy("Count"), _ => true), "An unreadable archive cannot be overwritten by settings."); - Append(unreadable, Entry("session-only", now), now); + Require(Call(unreadable, "Append", Entry("session-only", now), now) is false, + "An unreadable archive reports that the session result was not saved."); Require(File.ReadAllText(malformed) == "{broken", "An unreadable archive also survives future recording intact."); string futurePath = Path.Combine(scratch, "future-version.json"); File.WriteAllText(futurePath, "{\"Version\":99,\"Entries\":[],\"Policy\":{\"Mode\":0,\"Count\":100,\"Days\":30}}"); diff --git a/tests/SpeedTest.Tests/PreferencePersistenceTests.cs b/tests/SpeedTest.Tests/PreferencePersistenceTests.cs new file mode 100644 index 00000000..b0e0d427 --- /dev/null +++ b/tests/SpeedTest.Tests/PreferencePersistenceTests.cs @@ -0,0 +1,127 @@ +using System; +using System.IO; +using System.Reflection; +using System.Collections.Generic; +using System.Linq; +using KillerScan.Services; + +internal static class PreferencePersistenceTests +{ + internal static void Run() + { + string scratch = Path.Combine(Path.GetTempPath(), "KillerScan-preferences-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(scratch); + try + { + var type = typeof(KillerScan.Services.DeviceOverrides); + var pathField = type.GetField("FilePath", BindingFlags.NonPublic | BindingFlags.Static)!; + object originalPath = pathField.GetValue(null)!; + string path = Path.Combine(scratch, "overrides.json"); + try + { + pathField.SetValue(null, path); + File.WriteAllText(path, "{unreadable-original"); + KillerScan.Services.DeviceOverrides.Load(); + bool saved = KillerScan.Services.DeviceOverrides.Set("00:11:22:33:44:55", "Printer"); + if (File.ReadAllText(path) != "{unreadable-original") + throw new InvalidOperationException("Saving a device override must preserve unreadable original bytes."); + if (saved || string.IsNullOrEmpty(KillerScan.Services.DeviceOverrides.SaveError) || + KillerScan.Services.DeviceOverrides.Get("00:11:22:33:44:55") != "Printer") + throw new InvalidOperationException("An override save failure reports failure while retaining the session edit."); + File.WriteAllText(path, "{\"AA:BB:CC:DD:EE:FF\":\"Router\"}"); + if (!KillerScan.Services.DeviceOverrides.Save()) throw new InvalidOperationException("Retry succeeds after the storage fault is repaired."); + KillerScan.Services.DeviceOverrides.Load(); + if (KillerScan.Services.DeviceOverrides.Get("00:11:22:33:44:55") != "Printer" || + KillerScan.Services.DeviceOverrides.Get("AA:BB:CC:DD:EE:FF") != "Router") + throw new InvalidOperationException("Retry retains both the pending mutation and another writer's data."); + byte[] beforeDenied = File.ReadAllBytes(path); + using (var locked = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + if (KillerScan.Services.DeviceOverrides.Set("00:11:22:33:44:55", "Server")) + throw new InvalidOperationException("Denied replacement must not report success."); + if (!System.Linq.Enumerable.SequenceEqual(beforeDenied, File.ReadAllBytes(path))) + throw new InvalidOperationException("Denied replacement preserves prior committed bytes."); + } + if (!KillerScan.Services.DeviceOverrides.Save()) throw new InvalidOperationException("A denied replacement can be retried."); + KillerScan.Services.DeviceOverrides.Load(); + if (KillerScan.Services.DeviceOverrides.Get("00:11:22:33:44:55") != "Server") + throw new InvalidOperationException("Retry publishes the full intended change."); + } + finally { pathField.SetValue(null, originalPath); } + + var logins = typeof(KillerScan.App).Assembly.GetType("KillerScan.Services.DeviceLogins", true)!; + var loginPath = logins.GetField("FilePath", BindingFlags.NonPublic | BindingFlags.Static)!; + object originalLoginPath = loginPath.GetValue(null)!; + string loginFile = Path.Combine(scratch, "logins.json"); + try + { + loginPath.SetValue(null, loginFile); + File.WriteAllText(loginFile, "{unreadable-logins"); + logins.GetMethod("Load")!.Invoke(null, null); + logins.GetMethod("Set")!.Invoke(null, new object[] { "00:11:22:33:44:55", "192.0.2.1", "fixture-user" }); + if (File.ReadAllText(loginFile) != "{unreadable-logins") + throw new InvalidOperationException("Saving a username must preserve unreadable original bytes."); + } + finally { loginPath.SetValue(null, originalLoginPath); } + + var profiles = typeof(KillerScan.App).Assembly.GetType("KillerScan.Services.ScanProfiles", true)!; + var profilePath = profiles.GetField("FilePath", BindingFlags.NonPublic | BindingFlags.Static)!; + object originalProfilePath = profilePath.GetValue(null)!; + string profileFile = Path.Combine(scratch, "profiles.json"); + try + { + profilePath.SetValue(null, profileFile); + File.WriteAllText(profileFile, "{unreadable-profiles"); + profiles.GetMethod("Load")!.Invoke(null, null); + object profile = Activator.CreateInstance(typeof(KillerScan.App).Assembly.GetType("KillerScan.Services.ScanProfile", true)!)!; + profile.GetType().GetProperty("Name")!.SetValue(profile, "Fixture profile"); + profiles.GetMethod("Save")!.Invoke(null, new[] { profile }); + if (File.ReadAllText(profileFile) != "{unreadable-profiles") + throw new InvalidOperationException("Saving a profile must preserve unreadable original bytes."); + } + finally { profilePath.SetValue(null, originalProfilePath); } + + var preferences = typeof(KillerScan.App).Assembly.GetType("KillerScan.Services.DevicePreferences", true)!; + var preferencesPath = preferences.GetField("FilePath", BindingFlags.NonPublic | BindingFlags.Static)!; + object originalPreferencesPath = preferencesPath.GetValue(null)!; + string preferencesFile = Path.Combine(scratch, "devices.json"); + try + { + preferencesPath.SetValue(null, preferencesFile); + File.WriteAllText(preferencesFile, "{unreadable-preferences"); + preferences.GetMethod("Load")!.Invoke(null, null); + object device = new KillerScan.Engine.NetworkDevice { IpAddress = "192.0.2.1", MacAddress = "00:11:22:33:44:55" }; + preferences.GetMethod("SetName")!.Invoke(null, new[] { device, "Fixture name" }); + if (File.ReadAllText(preferencesFile) != "{unreadable-preferences") + throw new InvalidOperationException("Saving a device name must preserve unreadable original bytes."); + } + finally { preferencesPath.SetValue(null, originalPreferencesPath); } + + string faultFile = Path.Combine(scratch, "partial-write.json"); + File.WriteAllText(faultFile, "{\"committed\":\"original\"}"); + byte[] committed = File.ReadAllBytes(faultFile); + bool failWrite = true; + var faultStore = new JsonPreferenceFile>(faultFile, () => new(), value => value, (stream, bytes) => + { + if (failWrite) + { + stream.Write(bytes, 0, bytes.Length / 2); + throw new IOException("Controlled storage failure after partial output."); + } + stream.Write(bytes, 0, bytes.Length); + }); + faultStore.Load(); + if (faultStore.Change(value => value["pending"] = "retry me") || !File.ReadAllBytes(faultFile).SequenceEqual(committed)) + throw new InvalidOperationException("Partial output failure must preserve the complete original and report failure."); + if (faultStore.Value["pending"] != "retry me") throw new InvalidOperationException("Partial output preserves the unsaved session change."); + failWrite = false; + if (!faultStore.Save()) throw new InvalidOperationException("Partial output can be retried."); + faultStore.Load(); + if (faultStore.Value["committed"] != "original" || faultStore.Value["pending"] != "retry me") + throw new InvalidOperationException("Retry publishes the full original plus intended mutation."); + if (Directory.EnumerateFiles(scratch, "*.tmp").Any()) throw new InvalidOperationException("Failed and successful preference writes clean up temporary siblings."); + } + finally { Directory.Delete(scratch, true); } + Console.WriteLine("PASS: preference saves preserve unreadable originals."); + } +} diff --git a/tests/SpeedTest.Tests/Program.cs b/tests/SpeedTest.Tests/Program.cs index 54e5ebaf..ac17ba2e 100644 --- a/tests/SpeedTest.Tests/Program.cs +++ b/tests/SpeedTest.Tests/Program.cs @@ -30,6 +30,7 @@ private static async Task Main(string[] args) try { if (args.SequenceEqual(new[] { "--history-retention" })) { HistoryRetentionTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--preference-persistence" })) { PreferencePersistenceTests.Run(); return 0; } if (args.Length == 4 && args[0] == "--history-writer") { HistoryRetentionTests.WriteConcurrent(args[1], args[2], args[3]); return 0; } if (args.SequenceEqual(new[] { "--scan-presentation" })) { await ScanPresentation(); return 0; } if (args.SequenceEqual(new[] { "--terminal" })) { await View(); return 0; } From bca4a05fe8cdd13d186b82c5692c084984c01255 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:26:47 -0700 Subject: [PATCH 009/333] v1.8.4: Record successful empty scans in history --- CHANGELOG.md | 1 + Controls/ScanWorkspace.xaml.cs | 5 +- Controls/ScanWorkspace/Scanning.cs | 4 +- Engine/Scanning/NetworkScanner.cs | 7 +-- Models/ScanSession.cs | 4 +- tests/SpeedTest.Tests/HistoryUiTests.cs | 14 ++--- tests/SpeedTest.Tests/Program.cs | 1 + tests/SpeedTest.Tests/ScanLifecycleTests.cs | 58 +++++++++++++++++++++ 8 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 tests/SpeedTest.Tests/ScanLifecycleTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 549edf3b..aa696f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ### Fixed - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. +- Successful scans with no devices are saved in history and show devices that disappeared. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. diff --git a/Controls/ScanWorkspace.xaml.cs b/Controls/ScanWorkspace.xaml.cs index 4a66f195..56501769 100644 --- a/Controls/ScanWorkspace.xaml.cs +++ b/Controls/ScanWorkspace.xaml.cs @@ -58,6 +58,9 @@ public partial class ScanWorkspace : UserControl, IDisposable public double Progress => ScanProgress.Value; public bool IsProgressVisible => ScanProgress.Visibility == Visibility.Visible; public ScanWorkspace(string initialTarget = "", bool demo = false) + : this(initialTarget, demo, new NetworkScanner()) { } + + internal ScanWorkspace(string initialTarget, bool demo, NetworkScanner scanner) { DemoMode = demo; InitializeComponent(); @@ -66,7 +69,7 @@ public ScanWorkspace(string initialTarget = "", bool demo = false) ProgressVisibilityDescriptor.AddValueChanged(ScanProgress, NotifyFooterChanged); PopulateNetworkInfo(); if (!string.IsNullOrWhiteSpace(initialTarget)) SubnetInput.Text = initialTarget; - _active = new ScanSession(SubnetInput.Text) + _active = new ScanSession(SubnetInput.Text, scanner) { Status = string.Format(Loc("Str_Status_Ready"), OuiLookup.Count.ToString("N0")) }; WireSession(_active); ActivateSession(); diff --git a/Controls/ScanWorkspace/Scanning.cs b/Controls/ScanWorkspace/Scanning.cs index 0c4395b3..7935df91 100644 --- a/Controls/ScanWorkspace/Scanning.cs +++ b/Controls/ScanWorkspace/Scanning.cs @@ -74,14 +74,14 @@ private async void ScanBtn_Click(object sender, RoutedEventArgs e) { await _active.Scanner.ScanSubnetAsync(_active.SubnetText, cts.Token, fullScan: true); completed = !cts.IsCancellationRequested && !_disposed; - if (completed && _active.Devices.Count > 0) + if (completed) { var recorded = ScanHistory.Record(_active.ScannedSubnet, _active.Devices); // Tell the shell so the history sidebar picks up the new entry rather than // showing a stale list until it is next reopened. if (PersistenceFeedback.Check(recorded.Saved, ScanHistory.Retry, () => ScanHistory.SaveError)) HistoryRecorded?.Invoke(this, EventArgs.Empty); - if (!DevicePreferences.HasTrustedDevices) + if (_active.Devices.Count > 0 && !DevicePreferences.HasTrustedDevices) { PersistenceFeedback.Check(DevicePreferences.TrustAll(_active.Devices), DevicePreferences.Retry, () => DevicePreferences.SaveError); StatusText.Text = string.Format(Loc("Str_St_TrustedBaseline"), _active.Devices.Count); diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 86312496..503a1472 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -69,6 +69,7 @@ public class NetworkScanner public event Action? StatusChanged; public event Action? ProgressChanged; public event Action? DeviceFound; + protected void ReportDeviceFound(NetworkDevice device) => DeviceFound?.Invoke(device); /// /// A manual device type keyed by MAC address. When it returns a value, that type wins over @@ -123,7 +124,7 @@ public static List GetAddressesInSubnet(string cidr) /// /// Scan all hosts in the given subnet using ARP + ping combined approach. /// - public async Task> ScanSubnetAsync(string cidr, CancellationToken ct, bool fullScan = true) + public virtual async Task> ScanSubnetAsync(string cidr, CancellationToken ct, bool fullScan = true) { FlushLocalDnsCache(); // ScanTargets handles one or many comma-separated targets (CIDR, single host, or a @@ -262,7 +263,7 @@ public async Task> ScanSubnetAsync(string cidr, Cancellation { var device = await ProbeHostAsync(entry.Addr, entry.Mac, ct); ct.ThrowIfCancellationRequested(); - DeviceFound?.Invoke(device); + ReportDeviceFound(device); int done = Interlocked.Increment(ref completed); ProgressChanged?.Invoke(40 + (int)(done * 60.0 / total)); return device; @@ -299,7 +300,7 @@ public async Task> ScanSubnetAsync(string cidr, Cancellation device.DeviceType = ClassifyDevice(device); DeviceCompleted?.Invoke(device); - DeviceFound?.Invoke(device); + ReportDeviceFound(device); int done = Interlocked.Increment(ref completed); ProgressChanged?.Invoke(40 + (int)(done * 60.0 / total)); return device; diff --git a/Models/ScanSession.cs b/Models/ScanSession.cs index f8c4b427..4da872a4 100644 --- a/Models/ScanSession.cs +++ b/Models/ScanSession.cs @@ -5,10 +5,10 @@ namespace KillerScan.Models { // Each scan tab owns its targets, results, scanner, and cancellation lifetime. - internal sealed class ScanSession(string subnet) + internal sealed class ScanSession(string subnet, NetworkScanner? scanner = null) { public ObservableCollection Devices { get; } = []; - public NetworkScanner Scanner { get; } = new(); + public NetworkScanner Scanner { get; } = scanner ?? new(); public CancellationTokenSource? Cts { get; set; } public string SubnetText { get; set; } = subnet; diff --git a/tests/SpeedTest.Tests/HistoryUiTests.cs b/tests/SpeedTest.Tests/HistoryUiTests.cs index 91e11c50..51abd1de 100644 --- a/tests/SpeedTest.Tests/HistoryUiTests.cs +++ b/tests/SpeedTest.Tests/HistoryUiTests.cs @@ -32,18 +32,19 @@ private sealed class TestApplication : KillerScan.App { protected override void private static object? Field(object target, string name) => target.GetType().GetField(name, Instance)!.GetValue(target); private static void Require(bool condition, string message) { _checks++; if (!condition) throw new InvalidOperationException(message); } - public static Task Run(bool settingsOnly = false, bool tableOnly = false, bool profilesOnly = false, bool menusOnly = false) + public static Task Run(bool settingsOnly = false, bool tableOnly = false, bool profilesOnly = false, bool menusOnly = false, bool lifecycleOnly = false) { Exception? failure = null; var thread = new Thread(() => { - try { CheckUi(settingsOnly, tableOnly, profilesOnly, menusOnly); } catch (Exception ex) { failure = ex; } + try { CheckUi(settingsOnly, tableOnly, profilesOnly, menusOnly, lifecycleOnly); } catch (Exception ex) { failure = ex; } finally { Dispatcher.CurrentDispatcher.InvokeShutdown(); } }); thread.SetApartmentState(ApartmentState.STA); thread.Start(); Require(thread.Join(TimeSpan.FromSeconds(120)), "History checks finish within two minutes."); if (failure != null) throw new InvalidOperationException("History UI regression failed.", failure); - Console.WriteLine("PASS: " + _checks + " history, context-menu, shortcut and rendering assertions."); + Console.WriteLine(lifecycleOnly ? "PASS: controlled workspace lifecycle setup." + : "PASS: " + _checks + " history, context-menu, shortcut and rendering assertions."); return Task.CompletedTask; } @@ -56,14 +57,14 @@ public static async Task RunIsolated() Console.Write(await output); Require(process.ExitCode == 0, "Isolated history suite: " + await error); } - private static void CheckUi(bool settingsOnly, bool tableOnly, bool profilesOnly, bool menusOnly) + private static void CheckUi(bool settingsOnly, bool tableOnly, bool profilesOnly, bool menusOnly, bool lifecycleOnly) { // Settings writes are redirected for this isolated test process, never to the user's app key. string scratch = "Software\\KillerScan-HistoryTests-" + Guid.NewGuid().ToString("N"); using var registry = Registry.CurrentUser.CreateSubKey(scratch); var currentUser = new IntPtr(unchecked((int)0x80000001)); Require(RegOverridePredefKey(currentUser, registry.Handle.DangerousGetHandle()) == 0, "Isolate registry settings."); - try { Capture(settingsOnly, tableOnly, profilesOnly, menusOnly); } + try { Capture(settingsOnly, tableOnly, profilesOnly, menusOnly, lifecycleOnly); } finally { RegOverridePredefKey(currentUser, IntPtr.Zero); @@ -71,7 +72,7 @@ private static void CheckUi(bool settingsOnly, bool tableOnly, bool profilesOnly } } - private static void Capture(bool settingsOnly, bool tableOnly, bool profilesOnly, bool menusOnly) + private static void Capture(bool settingsOnly, bool tableOnly, bool profilesOnly, bool menusOnly, bool lifecycleOnly) { var assembly = typeof(KillerScan.App).Assembly; typeof(Application).GetField("_resourceAssembly", Static)!.SetValue(null, assembly); @@ -109,6 +110,7 @@ void Locale(string name) ((Action?)localeManager.GetField("LocaleChanged", Static)!.GetValue(null))?.Invoke(); } Theme("Black"); Locale("EnUS"); + if (lifecycleOnly) { ScanLifecycleTests.Run(); return; } if (settingsOnly) { typeof(MainWindow).GetMethod("PublishGrainTile", Static)!.Invoke(null, null); diff --git a/tests/SpeedTest.Tests/Program.cs b/tests/SpeedTest.Tests/Program.cs index ac17ba2e..da650c8b 100644 --- a/tests/SpeedTest.Tests/Program.cs +++ b/tests/SpeedTest.Tests/Program.cs @@ -31,6 +31,7 @@ private static async Task Main(string[] args) { if (args.SequenceEqual(new[] { "--history-retention" })) { HistoryRetentionTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--preference-persistence" })) { PreferencePersistenceTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--scan-lifecycle" })) { await HistoryUiTests.Run(lifecycleOnly: true); return 0; } if (args.Length == 4 && args[0] == "--history-writer") { HistoryRetentionTests.WriteConcurrent(args[1], args[2], args[3]); return 0; } if (args.SequenceEqual(new[] { "--scan-presentation" })) { await ScanPresentation(); return 0; } if (args.SequenceEqual(new[] { "--terminal" })) { await View(); return 0; } diff --git a/tests/SpeedTest.Tests/ScanLifecycleTests.cs b/tests/SpeedTest.Tests/ScanLifecycleTests.cs new file mode 100644 index 00000000..d96f37dd --- /dev/null +++ b/tests/SpeedTest.Tests/ScanLifecycleTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using KillerScan.Controls; +using KillerScan.Engine; +using KillerScan.Services; + +internal static class ScanLifecycleTests +{ + private sealed class ControlledScanner : NetworkScanner + { + internal readonly Queue>>> Results = new(); + public override async Task> ScanSubnetAsync(string target, CancellationToken token, bool fullScan = true) + { + var devices = await Results.Dequeue()(); + foreach (var device in devices) ReportDeviceFound(device); + return devices; + } + } + + internal static void Run() + { + DemoData.Enabled = true; + ScanHistory.Load(); DevicePreferences.Load(); + var scanner = new ControlledScanner(); + scanner.Results.Enqueue(() => Task.FromResult(new List + { new() { IpAddress = "192.0.2.1", MacAddress = "00:11:22:33:44:55", OpenPorts = [80] } })); + scanner.Results.Enqueue(() => Task.FromResult(new List())); + scanner.Results.Enqueue(() => Task.FromCanceled>(new CancellationToken(true))); + scanner.Results.Enqueue(() => Task.FromException>(new InvalidOperationException("Controlled scan failure."))); + using var workspace = new ScanWorkspace("192.0.2.1", false, scanner); + int recorded = 0; + workspace.HistoryRecorded += (_, _) => recorded++; + workspace.Scan(); + Require(ScanHistory.Entries.Count == 1 && recorded == 1, "A populated successful scan is saved once."); + workspace.Scan(); + Require(ScanHistory.Entries.Count == 2 && recorded == 2, "A successful empty scan is saved once."); + var comparison = ScanHistory.Compare(ScanHistory.Entries.Last()); + Require(comparison.Current.Devices.Count == 0 && comparison.Removed.Count == 1, + "An empty successful result records disappearance of the previous device."); + workspace.Scan(); workspace.Scan(); + Require(ScanHistory.Entries.Count == 2 && recorded == 2, "Canceled and failed empty scans never masquerade as completed history."); + Require(!workspace.IsScanning, "The scan lifetime ends after success, cancellation and failure."); + ScanHistory.Load(); DevicePreferences.Load(); + var emptyScanner = new ControlledScanner(); + emptyScanner.Results.Enqueue(() => Task.FromResult(new List())); + using var emptyWorkspace = new ScanWorkspace("192.0.2.2", false, emptyScanner); + emptyWorkspace.Scan(); + Require(ScanHistory.Entries.Count == 1 && !DevicePreferences.HasTrustedDevices, + "A first empty scan is recorded without creating a trusted-device baseline."); + Console.WriteLine("PASS: populated, empty, canceled and failed scan history through the workspace command."); + } + + private static void Require(bool condition, string message) + { if (!condition) throw new InvalidOperationException(message); } +} From 9b924cd7328f9871ef207af5574041d973115c18 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:35:20 -0700 Subject: [PATCH 010/333] v1.8.4: Save final automatic deep-profile evidence and completion status --- CHANGELOG.md | 2 +- Controls/HistoryWorkspace.xaml.cs | 6 ++ Controls/ScanWorkspace/Rescan.cs | 30 +++++- Controls/ScanWorkspace/Scanning.cs | 18 ++-- Engine/Scanning/NetworkScanner.cs | 2 +- Services/ScanHistory.cs | 15 ++- Strings/bn.xaml | 1 + Strings/cs-CZ.xaml | 1 + Strings/de-DE.xaml | 1 + Strings/en-US.xaml | 1 + Strings/es.xaml | 1 + Strings/fr-FR.xaml | 1 + Strings/hu-HU.xaml | 1 + Strings/it-IT.xaml | 1 + Strings/ja-JP.xaml | 1 + Strings/kk-KZ.xaml | 1 + Strings/nb-NO.xaml | 1 + Strings/pl-PL.xaml | 1 + Strings/pt-BR.xaml | 1 + Strings/ru-RU.xaml | 1 + Strings/tr-TR.xaml | 1 + Strings/uk-UA.xaml | 1 + Strings/vi-VN.xaml | 1 + Strings/zh-CN.xaml | 1 + Strings/zh-TW.xaml | 1 + .../SpeedTest.Tests/HistoryRetentionTests.cs | 9 ++ tests/SpeedTest.Tests/ScanLifecycleTests.cs | 92 +++++++++++++++++++ 27 files changed, 176 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa696f4f..dc819ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ### Fixed - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. -- Successful scans with no devices are saved in history and show devices that disappeared. +- Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. diff --git a/Controls/HistoryWorkspace.xaml.cs b/Controls/HistoryWorkspace.xaml.cs index 3e442e86..c0cc26bb 100644 --- a/Controls/HistoryWorkspace.xaml.cs +++ b/Controls/HistoryWorkspace.xaml.cs @@ -94,6 +94,12 @@ public void RefreshLocale() string.Format(Loc("Str_History_ComparedWith"), comparison.Previous.ScannedAt.ToString("g")); bool comparing = !_showAll && comparison?.Previous != null; HistoryTarget.Text = _entry?.Target ?? Loc("Str_History_Empty"); + if (_entry?.Completion is ScanCompletion.Partial or ScanCompletion.Canceled) + { + string state = Loc(_entry.Completion == ScanCompletion.Canceled ? "Str_St_ScanCanceled" : "Str_History_Partial"); + HistoryTarget.Text += " · " + state; + HistoryEntryContext.Text += " · " + state; + } HistoryDateSeparator.Text = _entry == null ? string.Empty : " · "; HistoryPreviousTimestamp.Text = comparing ? comparison!.Previous!.ScannedAt.ToString("g") : string.Empty; HistoryComparisonSeparator.Text = comparing ? " / " : string.Empty; diff --git a/Controls/ScanWorkspace/Rescan.cs b/Controls/ScanWorkspace/Rescan.cs index 31f99a05..2f2e89f3 100644 --- a/Controls/ScanWorkspace/Rescan.cs +++ b/Controls/ScanWorkspace/Rescan.cs @@ -100,6 +100,9 @@ private async void RescanSelected_Click(object sender, RoutedEventArgs e) } private async void DeepScanAll_Click(object sender, RoutedEventArgs e) + => await DeepScanAllAsync(); + + private async Task DeepScanAllAsync(bool recordProfile = false) { if (_rescanCts != null) { @@ -116,16 +119,16 @@ private async void DeepScanAll_Click(object sender, RoutedEventArgs e) using var hostGate = new SemaphoreSlim(8); int done = 0; int refreshed = 0; + var completion = ScanCompletion.Partial; ScanBtn.IsEnabled = false; ScanProgress.Value = 0; ScanProgress.Visibility = Visibility.Visible; UpdateDeepScanButton(); StateChanged?.Invoke(this, EventArgs.Empty); - NetworkScanner.FlushLocalDnsCache(); - try { + NetworkScanner.FlushLocalDnsCache(); var tasks = targets.Select(async old => { await hostGate.WaitAsync(ct); @@ -157,17 +160,34 @@ private async void DeepScanAll_Click(object sender, RoutedEventArgs e) }); await Task.WhenAll(tasks); + completion = refreshed == targets.Count ? ScanCompletion.Complete : ScanCompletion.Partial; if (!_disposed) StatusText.Text = string.Format(Loc("Str_St_RescanDone"), refreshed); } catch (OperationCanceledException) { + completion = ScanCompletion.Canceled; if (!_disposed) StatusText.Text = Loc("Str_St_ScanCanceled"); } + catch (Exception ex) + { + if (!_disposed) StatusText.Text = string.Format(Loc("Str_Err_Scan"), ex.Message); + } finally { - _rescanCts.Dispose(); - _rescanCts = null; - FinishRescanUi(); + try + { + if (recordProfile) + { + _scanCompleted = completion == ScanCompletion.Complete; + RecordCompletedRun(completion); + } + } + finally + { + _rescanCts.Dispose(); + _rescanCts = null; + FinishRescanUi(); + } } } diff --git a/Controls/ScanWorkspace/Scanning.cs b/Controls/ScanWorkspace/Scanning.cs index 7935df91..7819d235 100644 --- a/Controls/ScanWorkspace/Scanning.cs +++ b/Controls/ScanWorkspace/Scanning.cs @@ -76,11 +76,7 @@ private async void ScanBtn_Click(object sender, RoutedEventArgs e) completed = !cts.IsCancellationRequested && !_disposed; if (completed) { - var recorded = ScanHistory.Record(_active.ScannedSubnet, _active.Devices); - // Tell the shell so the history sidebar picks up the new entry rather than - // showing a stale list until it is next reopened. - if (PersistenceFeedback.Check(recorded.Saved, ScanHistory.Retry, () => ScanHistory.SaveError)) - HistoryRecorded?.Invoke(this, EventArgs.Empty); + if (!_runDeepAfterScan || _active.Devices.Count == 0) RecordCompletedRun(); if (_active.Devices.Count > 0 && !DevicePreferences.HasTrustedDevices) { PersistenceFeedback.Check(DevicePreferences.TrustAll(_active.Devices), DevicePreferences.Retry, () => DevicePreferences.SaveError); @@ -99,7 +95,7 @@ private async void ScanBtn_Click(object sender, RoutedEventArgs e) { _active.Cts = null; cts.Dispose(); - bool deep = completed && _runDeepAfterScan && !_disposed; + bool deep = completed && _runDeepAfterScan && _active.Devices.Count > 0 && !_disposed; _runDeepAfterScan = false; _scanCompleted = completed; if (!_disposed) @@ -111,8 +107,16 @@ private async void ScanBtn_Click(object sender, RoutedEventArgs e) UpdateDeepScanButton(); StateChanged?.Invoke(this, EventArgs.Empty); } - if (deep) DeepScan(); + if (deep) await DeepScanAllAsync(recordProfile: true); } } + + private void RecordCompletedRun(ScanCompletion completion = ScanCompletion.Complete) + { + var recorded = ScanHistory.Record(_active.ScannedSubnet, _active.Devices, completion); + if (_disposed) return; + if (PersistenceFeedback.Check(recorded.Saved, ScanHistory.Retry, () => ScanHistory.SaveError)) + HistoryRecorded?.Invoke(this, EventArgs.Empty); + } } } diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 503a1472..e8b1ae1c 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -466,7 +466,7 @@ private async Task ProbeHostAsync(IPAddress addr, string cachedMa // per host) on purpose - it targets one host on demand, not a whole /24. // Reuses the last full scan's multicast (mDNS/SSDP) results for naming. // ------------------------------------------------------------------- - public async Task DeepProbeHostAsync( + public virtual async Task DeepProbeHostAsync( string ip, CancellationToken ct, int portConcurrency = 256, bool flushLocalDnsCache = true) { diff --git a/Services/ScanHistory.cs b/Services/ScanHistory.cs index 4275a89e..7a75c487 100644 --- a/Services/ScanHistory.cs +++ b/Services/ScanHistory.cs @@ -3,15 +3,23 @@ namespace KillerScan.Services { + internal enum ScanCompletion { Complete, Partial, Canceled } + internal sealed class ScanHistoryEntry { public DateTimeOffset ScannedAt { get; set; } public string Target { get; set; } = string.Empty; public List Devices { get; set; } = []; + public ScanCompletion Completion { get; set; } /// What a screen reader announces for the entry's row in the history list. - public override string ToString() => - ScannedAt.ToString("g", System.Globalization.CultureInfo.CurrentCulture) + ", " + Target; + public override string ToString() + { + string text = ScannedAt.ToString("g", System.Globalization.CultureInfo.CurrentCulture) + ", " + Target; + if (Completion == ScanCompletion.Complete) return text; + string key = Completion == ScanCompletion.Canceled ? "Str_St_ScanCanceled" : "Str_History_Partial"; + return text + ", " + (System.Windows.Application.Current?.TryFindResource(key) as string ?? Completion.ToString()); + } } internal sealed class HistoricalDevice @@ -101,12 +109,13 @@ internal static void SeedDemo(IEnumerable entries) _demoRetention = new(HistoryRetentionMode.SaveAll); } - public static ScanComparison Record(string target, IEnumerable devices) + public static ScanComparison Record(string target, IEnumerable devices, ScanCompletion completion = ScanCompletion.Complete) { var current = new ScanHistoryEntry { ScannedAt = DateTimeOffset.Now, Target = target, + Completion = completion, Devices = [.. devices.Select(ToHistorical).OrderBy(device => device.IpAddress)] }; var previous = _entries.LastOrDefault(entry => diff --git a/Strings/bn.xaml b/Strings/bn.xaml index 911cd156..8189ab2f 100644 --- a/Strings/bn.xaml +++ b/Strings/bn.xaml @@ -504,4 +504,5 @@ এক পৃষ্ঠা বাঁয়ে এক পৃষ্ঠা ডানে পরিবর্তনগুলো সংরক্ষণ করা যায়নি। এই সেশনে সেগুলো এখনও উপলভ্য আছে। আবার সংরক্ষণ করার চেষ্টা করবেন? {0} + আংশিক স্ক্যান diff --git a/Strings/cs-CZ.xaml b/Strings/cs-CZ.xaml index 5c8b233b..23a9aa7a 100644 --- a/Strings/cs-CZ.xaml +++ b/Strings/cs-CZ.xaml @@ -502,4 +502,5 @@ Vaše nastavení nebude odstraněno. O stránku doleva O stránku doprava Změny se nepodařilo uložit. V této relaci jsou stále dostupné. Zkusit uložit znovu? {0} + Částečné skenování diff --git a/Strings/de-DE.xaml b/Strings/de-DE.xaml index e9fd9973..1aa09ffe 100644 --- a/Strings/de-DE.xaml +++ b/Strings/de-DE.xaml @@ -505,4 +505,5 @@ Ihre Einstellungen bleiben erhalten. Seite nach links Seite nach rechts Die Änderungen konnten nicht gespeichert werden. Sie bleiben für diese Sitzung verfügbar. Erneut speichern? {0} + Unvollständiger Scan diff --git a/Strings/en-US.xaml b/Strings/en-US.xaml index 090e9a24..1aa3f8d9 100644 --- a/Strings/en-US.xaml +++ b/Strings/en-US.xaml @@ -546,4 +546,5 @@ Your settings will not be removed. Page left Page right Could not save changes. They are still available for this session. Try saving again? {0} + Partial scan diff --git a/Strings/es.xaml b/Strings/es.xaml index 7dad3d3d..25ddc3e7 100644 --- a/Strings/es.xaml +++ b/Strings/es.xaml @@ -504,4 +504,5 @@ Su configuración no se eliminará. Una página hacia la izquierda Una página hacia la derecha No se pudieron guardar los cambios. Siguen disponibles durante esta sesión. ¿Intentar guardarlos de nuevo? {0} + Escaneo parcial diff --git a/Strings/fr-FR.xaml b/Strings/fr-FR.xaml index 876c76ed..61cf7825 100644 --- a/Strings/fr-FR.xaml +++ b/Strings/fr-FR.xaml @@ -504,4 +504,5 @@ Vos paramètres ne seront pas supprimés. Page vers la gauche Page vers la droite Impossible d’enregistrer les modifications. Elles restent disponibles pendant cette session. Réessayer de les enregistrer ? {0} + Analyse partielle diff --git a/Strings/hu-HU.xaml b/Strings/hu-HU.xaml index b8734bbc..badea718 100644 --- a/Strings/hu-HU.xaml +++ b/Strings/hu-HU.xaml @@ -520,4 +520,5 @@ A beállítások nem törlődnek. Egy oldallal balra Egy oldallal jobbra A módosításokat nem sikerült menteni. Ebben a munkamenetben továbbra is elérhetők. Megpróbálja újra menteni? {0} + Részleges vizsgálat diff --git a/Strings/it-IT.xaml b/Strings/it-IT.xaml index 9b00c846..4e919c23 100644 --- a/Strings/it-IT.xaml +++ b/Strings/it-IT.xaml @@ -520,4 +520,5 @@ Le tue impostazioni non verranno rimosse. Pagina a sinistra Pagina a destra Impossibile salvare le modifiche. Restano disponibili per questa sessione. Riprovare a salvarle? {0} + Scansione parziale diff --git a/Strings/ja-JP.xaml b/Strings/ja-JP.xaml index dab1cdf3..1ca11230 100644 --- a/Strings/ja-JP.xaml +++ b/Strings/ja-JP.xaml @@ -515,4 +515,5 @@ 1 ページ左へ 1 ページ右へ 変更を保存できませんでした。このセッションでは引き続き利用できます。もう一度保存しますか? {0} + 未完了のスキャン diff --git a/Strings/kk-KZ.xaml b/Strings/kk-KZ.xaml index 09970f64..025486b8 100644 --- a/Strings/kk-KZ.xaml +++ b/Strings/kk-KZ.xaml @@ -546,4 +546,5 @@ Бір бет солға Бір бет оңға Өзгерістерді сақтау мүмкін болмады. Олар осы сеанс кезінде қолжетімді болып қалады. Қайта сақтап көресіз бе? {0} + Ішінара сканерлеу diff --git a/Strings/nb-NO.xaml b/Strings/nb-NO.xaml index 48c74f1f..04d4057d 100644 --- a/Strings/nb-NO.xaml +++ b/Strings/nb-NO.xaml @@ -546,4 +546,5 @@ Innstillingene dine fjernes ikke. Side til venstre Side til høyre Kunne ikke lagre endringene. De er fortsatt tilgjengelige i denne økten. Prøve å lagre på nytt? {0} + Delvis skanning diff --git a/Strings/pl-PL.xaml b/Strings/pl-PL.xaml index d74bfae8..bb361485 100644 --- a/Strings/pl-PL.xaml +++ b/Strings/pl-PL.xaml @@ -489,4 +489,5 @@ Twoje ustawienia nie zostaną usunięte. Strona w lewo Strona w prawo Nie udało się zapisać zmian. Nadal są dostępne w tej sesji. Spróbować zapisać ponownie? {0} + Skanowanie częściowe diff --git a/Strings/pt-BR.xaml b/Strings/pt-BR.xaml index f6d4ec71..5137c706 100644 --- a/Strings/pt-BR.xaml +++ b/Strings/pt-BR.xaml @@ -546,4 +546,5 @@ Suas configurações não serão removidas. Uma página à esquerda Uma página à direita Não foi possível salvar as alterações. Elas continuam disponíveis nesta sessão. Tentar salvar novamente? {0} + Verificação parcial diff --git a/Strings/ru-RU.xaml b/Strings/ru-RU.xaml index b8af0480..d604f171 100644 --- a/Strings/ru-RU.xaml +++ b/Strings/ru-RU.xaml @@ -530,4 +530,5 @@ На страницу влево На страницу вправо Не удалось сохранить изменения. Они остаются доступны в этом сеансе. Попробовать сохранить снова? {0} + Неполное сканирование diff --git a/Strings/tr-TR.xaml b/Strings/tr-TR.xaml index e6dd70c4..b69b9304 100644 --- a/Strings/tr-TR.xaml +++ b/Strings/tr-TR.xaml @@ -504,4 +504,5 @@ Ayarlarınız silinmeyecek. Bir sayfa sola Bir sayfa sağa Değişiklikler kaydedilemedi. Bu oturumda kullanılabilir olmaya devam ederler. Yeniden kaydetmek ister misiniz? {0} + Kısmi tarama diff --git a/Strings/uk-UA.xaml b/Strings/uk-UA.xaml index c9833c1e..11a0173d 100644 --- a/Strings/uk-UA.xaml +++ b/Strings/uk-UA.xaml @@ -546,4 +546,5 @@ На сторінку ліворуч На сторінку праворуч Не вдалося зберегти зміни. Вони залишаються доступними в цьому сеансі. Спробувати зберегти ще раз? {0} + Неповне сканування diff --git a/Strings/vi-VN.xaml b/Strings/vi-VN.xaml index 2b7edb1c..0277348f 100644 --- a/Strings/vi-VN.xaml +++ b/Strings/vi-VN.xaml @@ -485,4 +485,5 @@ Cài đặt của bạn sẽ không bị xóa. Sang trái một trang Sang phải một trang Không thể lưu thay đổi. Các thay đổi vẫn có sẵn trong phiên này. Thử lưu lại? {0} + Quét chưa hoàn tất diff --git a/Strings/zh-CN.xaml b/Strings/zh-CN.xaml index f967afdb..e729b8b5 100644 --- a/Strings/zh-CN.xaml +++ b/Strings/zh-CN.xaml @@ -504,4 +504,5 @@ 向左翻页 向右翻页 无法保存更改。这些更改在当前会话中仍然可用。是否重新尝试保存? {0} + 未完成的扫描 diff --git a/Strings/zh-TW.xaml b/Strings/zh-TW.xaml index 7bb3ad30..59960225 100644 --- a/Strings/zh-TW.xaml +++ b/Strings/zh-TW.xaml @@ -504,4 +504,5 @@ 向左翻頁 向右翻頁 無法儲存變更。這些變更在目前工作階段中仍可使用。是否重新嘗試儲存? {0} + 未完成的掃描 diff --git a/tests/SpeedTest.Tests/HistoryRetentionTests.cs b/tests/SpeedTest.Tests/HistoryRetentionTests.cs index 0528499f..7c056dab 100644 --- a/tests/SpeedTest.Tests/HistoryRetentionTests.cs +++ b/tests/SpeedTest.Tests/HistoryRetentionTests.cs @@ -82,6 +82,15 @@ void Fails(Action action, string message) var freshReload = Archive("new.json"); Load(freshReload, now); Require(Entries(freshReload).Count == 100 && Mode(freshReload) == "Count", "Default policy and history survive reload."); + var partialArchive = Archive("completion.json"); + var partialEntry = Entry("partial", now); + var completionProperty = entryType.GetProperty("Completion")!; + completionProperty.SetValue(partialEntry, Enum.Parse(completionProperty.PropertyType, "Partial")); + Append(partialArchive, partialEntry, now); + var completionReload = Archive("completion.json"); Load(completionReload, now); + Require(Property(Entries(completionReload)[0]!, "Completion")!.ToString() == "Partial", + "Incomplete scan status survives actual archive publication and reload."); + var writerA = Archive("concurrent.json"); var writerB = Archive("concurrent.json"); Load(writerA, now); Load(writerB, now); diff --git a/tests/SpeedTest.Tests/ScanLifecycleTests.cs b/tests/SpeedTest.Tests/ScanLifecycleTests.cs index d96f37dd..9a2df2d7 100644 --- a/tests/SpeedTest.Tests/ScanLifecycleTests.cs +++ b/tests/SpeedTest.Tests/ScanLifecycleTests.cs @@ -12,16 +12,22 @@ internal static class ScanLifecycleTests private sealed class ControlledScanner : NetworkScanner { internal readonly Queue>>> Results = new(); + internal Func>? DeepResult; + internal Func>? PendingDeep; public override async Task> ScanSubnetAsync(string target, CancellationToken token, bool fullScan = true) { var devices = await Results.Dequeue()(); foreach (var device in devices) ReportDeviceFound(device); return devices; } + public override Task DeepProbeHostAsync(string ip, CancellationToken token, + int portConcurrency = 256, bool flushLocalDnsCache = true) => PendingDeep?.Invoke(ip, token) ?? DeepResult!(ip); } internal static void Run() { + SynchronizationContext.SetSynchronizationContext(new System.Windows.Threading.DispatcherSynchronizationContext( + System.Windows.Threading.Dispatcher.CurrentDispatcher)); DemoData.Enabled = true; ScanHistory.Load(); DevicePreferences.Load(); var scanner = new ControlledScanner(); @@ -50,9 +56,95 @@ internal static void Run() emptyWorkspace.Scan(); Require(ScanHistory.Entries.Count == 1 && !DevicePreferences.HasTrustedDevices, "A first empty scan is recorded without creating a trusted-device baseline."); + CheckDeepProfile(); Console.WriteLine("PASS: populated, empty, canceled and failed scan history through the workspace command."); } + private static void CheckDeepProfile() + { + var originalPlatform = NetworkPlatform.Current; + NetworkPlatform.Current = new UnsupportedNetworkPlatform(); + try + { + ScanHistory.Load(); DevicePreferences.Load(); + ScanHistory.Record("192.0.2.1", [new NetworkDevice { IpAddress = "192.0.2.1", OpenPorts = [22] }]); + var scanner = new ControlledScanner(); + scanner.Results.Enqueue(() => Task.FromResult(new List + { new() { IpAddress = "192.0.2.1", OpenPorts = [80] } })); + scanner.DeepResult = ip => Task.FromResult(new NetworkDevice { IpAddress = ip, OpenPorts = [80, 8443] }); + using var workspace = new ScanWorkspace("192.0.2.1", false, scanner); + int recorded = 0; + workspace.HistoryRecorded += (_, _) => recorded++; + workspace.RunProfile(new ScanProfile { Name = "Deep fixture", Target = "192.0.2.1", DeepScanAfter = true }); + Require(ScanHistory.Entries.Count == 2 && recorded == 1 && ScanHistory.Entries.Last().Devices.Single().OpenPorts.SequenceEqual(new[] { 80, 8443 }), + "A profile's one completed history snapshot includes its automatic deep-only port."); + Require(ScanHistory.Entries.First().Devices.Single().OpenPorts.SequenceEqual(new[] { 22 }), + "Automatic deep completion never changes the previous snapshot."); + scanner.Results.Enqueue(() => Task.FromResult(new List + { new() { IpAddress = "192.0.2.1", OpenPorts = [80] } })); + scanner.DeepResult = _ => Task.FromCanceled(new CancellationToken(true)); + workspace.RunProfile(new ScanProfile { Target = "192.0.2.1", DeepScanAfter = true }); + Require(ScanHistory.Entries.Count == 3 && recorded == 2 && ScanHistory.Entries.Last().Completion == ScanCompletion.Canceled, + "A canceled automatic deep phase saves one explicitly canceled snapshot."); + var history = new HistoryWorkspace(); + history.ShowEntry(ScanHistory.Entries.Last()); + Require(((System.Windows.Documents.Run)history.FindName("HistoryTarget")).Text.Contains("Scan canceled"), + "Canceled history exposes its status in the selected snapshot's heading."); + scanner.Results.Enqueue(() => Task.FromResult(new List + { new() { IpAddress = "192.0.2.1", OpenPorts = [80] } })); + scanner.DeepResult = _ => Task.FromException(new InvalidOperationException("Controlled deep failure.")); + workspace.RunProfile(new ScanProfile { Target = "192.0.2.1", DeepScanAfter = true }); + Require(ScanHistory.Entries.Count == 4 && recorded == 3 && ScanHistory.Entries.Last().Completion == ScanCompletion.Partial, + "A failed deep phase saves one explicitly partial snapshot."); + history.ShowEntry(ScanHistory.Entries.Last()); + Require(((System.Windows.Documents.Run)history.FindName("HistoryTarget")).Text.Contains("Partial scan"), + "Partial history exposes its status in the selected snapshot's heading."); + Require(ScanHistory.Entries.Last().ToString().Contains("Partial scan"), "The history row's accessible name includes the partial status."); + scanner.DeepResult = ip => Task.FromResult(new NetworkDevice { IpAddress = ip, OpenPorts = [443] }); + workspace.DeepScan(); + Require(ScanHistory.Entries.Count == 4 && ScanHistory.Entries.Last().Devices.Single().OpenPorts.SequenceEqual(new[] { 80 }), + "A manual deep rescan keeps existing history snapshots immutable and does not add an automatic-profile entry."); + var grid = (System.Windows.Controls.DataGrid)workspace.FindName("ResultsGrid"); + grid.SelectedItem = workspace.Session.Devices.Single(); + ((System.Windows.Controls.MenuItem)workspace.FindName("RescanMenuItem")).RaiseEvent( + new System.Windows.RoutedEventArgs(System.Windows.Controls.MenuItem.ClickEvent)); + Require(ScanHistory.Entries.Count == 4 && ScanHistory.Entries.Last().Devices.Single().OpenPorts.SequenceEqual(new[] { 80 }), + "A selected manual rescan also leaves saved snapshots immutable."); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + scanner.PendingDeep = (_, token) => { token.Register(() => pending.TrySetCanceled()); return pending.Task; }; + scanner.Results.Enqueue(() => Task.FromResult(new List + { new() { IpAddress = "192.0.2.1", OpenPorts = [80] } })); + workspace.RunProfile(new ScanProfile { Target = "192.0.2.1", DeepScanAfter = true }); + Require(workspace.IsScanning && ScanHistory.Entries.Count == 4, "An automatic profile remains active and unsaved while its deep phase is pending."); + workspace.RunProfile(new ScanProfile { Target = "192.0.2.1", DeepScanAfter = true }); + workspace.Stop(); + PumpUntil(() => !workspace.IsScanning); + Require(ScanHistory.Entries.Count == 5 && recorded == 4 && ScanHistory.Entries.Last().Completion == ScanCompletion.Canceled, + "Stop cancels the active deep phase, rejects a rapid rerun, and records one canceled run."); + pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + scanner.Results.Enqueue(() => Task.FromResult(new List + { new() { IpAddress = "192.0.2.1", OpenPorts = [80] } })); + workspace.RunProfile(new ScanProfile { Target = "192.0.2.1", DeepScanAfter = true }); + workspace.Dispose(); + PumpUntil(() => !workspace.IsScanning); + Require(ScanHistory.Entries.Count == 6 && recorded == 4 && ScanHistory.Entries.Last().Completion == ScanCompletion.Canceled, + "Closing during an automatic deep phase preserves the canceled snapshot without late workspace notifications."); + } + finally { NetworkPlatform.Current = originalPlatform; } + } + + private static void PumpUntil(Func complete) + { + var frame = new System.Windows.Threading.DispatcherFrame(); + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(5) }; + timer.Tick += (_, _) => { if (complete() || elapsed.ElapsedMilliseconds > 2000) frame.Continue = false; }; + timer.Start(); + try { System.Windows.Threading.Dispatcher.PushFrame(frame); } + finally { timer.Stop(); } + Require(complete(), "The controlled scan lifetime finishes within two seconds."); + } + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } } From 6122f529b3ac54e6d94a71db21407b64128b5a96 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:41:56 -0700 Subject: [PATCH 011/333] v1.8.4: Capture export results and scan provenance before the save dialog --- CHANGELOG.md | 1 + Controls/ScanWorkspace.xaml.cs | 4 +- Controls/ScanWorkspace/DemoMode.cs | 2 + Controls/ScanWorkspace/Export.cs | 17 +++-- Controls/ScanWorkspace/Scanning.cs | 10 ++- Models/ScanSession.cs | 2 + Services/ReportExport.cs | 13 +++- tests/SpeedTest.Tests/ExportSnapshotTests.cs | 66 ++++++++++++++++++++ tests/SpeedTest.Tests/ScanLifecycleTests.cs | 2 + 9 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 tests/SpeedTest.Tests/ExportSnapshotTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index dc819ebf..699729d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. +- Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. diff --git a/Controls/ScanWorkspace.xaml.cs b/Controls/ScanWorkspace.xaml.cs index 56501769..fa89d3f8 100644 --- a/Controls/ScanWorkspace.xaml.cs +++ b/Controls/ScanWorkspace.xaml.cs @@ -187,11 +187,13 @@ internal void RunProfile(ScanProfile profile) _runDeepAfterScan = profile.DeepScanAfter; Scan(); } - internal void LoadSnapshot(string target, IEnumerable devices) + internal void LoadSnapshot(string target, IEnumerable devices, DateTimeOffset? scannedAt = null) { if (_disposed || IsScanning) return; Targets = target; _active.ScannedSubnet = target; + _active.ScannedAt = scannedAt ?? DateTimeOffset.Now; + _active.Completion = ScanCompletion.Complete; _active.Devices.Clear(); foreach (var device in devices) _active.Devices.Add(device); RefreshDeviceCount(); diff --git a/Controls/ScanWorkspace/DemoMode.cs b/Controls/ScanWorkspace/DemoMode.cs index e6b0fc13..aafbf6d7 100644 --- a/Controls/ScanWorkspace/DemoMode.cs +++ b/Controls/ScanWorkspace/DemoMode.cs @@ -31,6 +31,8 @@ private void GenerateDemoScan() foreach (var d in demo.Devices) _active.Devices.Add(d); _active.ScannedSubnet = demo.Subnet; + _active.ScannedAt = DateTimeOffset.Now; + _active.Completion = ScanCompletion.Complete; RefreshDeviceCount(); // Everything the other views read comes from the same fabricated network, so Topology, diff --git a/Controls/ScanWorkspace/Export.cs b/Controls/ScanWorkspace/Export.cs index c184c4b9..1e4cf179 100644 --- a/Controls/ScanWorkspace/Export.cs +++ b/Controls/ScanWorkspace/Export.cs @@ -12,6 +12,9 @@ namespace KillerScan.Controls public partial class ScanWorkspace { private string _exportContext = "scan"; + internal Func? ExportDialog { get; set; } + private bool? ShowExportDialog(FileDialog dialog) => ExportDialog is { } select + ? select(dialog) : dialog.ShowDialog(Window.GetWindow(this)); /// /// Which view the export button is acting for. "scan" means one of this control's own @@ -107,10 +110,11 @@ private void ExportCsv_Click(object sender, RoutedEventArgs e) Filter = Loc("Str_Filter_Csv") + "|*.csv", FileName = $"KillerScan_{DateTime.Now:yyyyMMdd_HHmmss}.csv" }; - if (dlg.ShowDialog(Window.GetWindow(this)) != true) return; try { - File.WriteAllText(dlg.FileName, ReportExport.BuildCsv(ActiveDevices), Encoding.UTF8); + string csv = ReportExport.BuildCsv(ActiveDevices); + if (ShowExportDialog(dlg) != true) return; + File.WriteAllText(dlg.FileName, csv, Encoding.UTF8); StatusText.Text = string.Format(Loc("Str_St_Exported"), Path.GetFileName(dlg.FileName)); } catch (Exception ex) @@ -128,7 +132,6 @@ private void ExportServicesCsv_Click(object sender, RoutedEventArgs e) Filter = Loc("Str_Filter_Csv") + "|*.csv", FileName = $"KillerScan_Services_{DateTime.Now:yyyyMMdd_HHmmss}.csv" }; - if (dlg.ShowDialog(Window.GetWindow(this)) != true) return; try { // English headers and blanket quoting, matching ReportExport.BuildCsv so both @@ -138,6 +141,7 @@ private void ExportServicesCsv_Click(object sender, RoutedEventArgs e) foreach (var row in ServicesGrid.Items.OfType()) text.AppendLine(string.Join(",", ReportExport.CsvField(row.Service), ReportExport.CsvField(row.Port.ToString()), ReportExport.CsvField(row.DeviceName), ReportExport.CsvField(row.IpAddress), ReportExport.CsvField(row.DeviceTypeDisplay))); + if (ShowExportDialog(dlg) != true) return; File.WriteAllText(dlg.FileName, text.ToString(), Encoding.UTF8); StatusText.Text = string.Format(Loc("Str_St_Exported"), Path.GetFileName(dlg.FileName)); } @@ -152,11 +156,12 @@ private void ExportHtml_Click(object sender, RoutedEventArgs e) Filter = Loc("Str_Filter_Html") + "|*.html", FileName = $"KillerScan_{DateTime.Now:yyyyMMdd_HHmmss}.html" }; - if (dlg.ShowDialog(Window.GetWindow(this)) != true) return; try { - string html = ReportExport.BuildHtml( - ActiveDevices, SubnetInput.Text, ThemeManager.Current.ToString().ToLowerInvariant()); + string html = ReportExport.BuildSnapshotHtml( + ActiveDevices, _active.ScannedSubnet, ThemeManager.Current.ToString().ToLowerInvariant(), + _active.ScannedAt, _active.Completion); + if (ShowExportDialog(dlg) != true) return; File.WriteAllText(dlg.FileName, html, Encoding.UTF8); StatusText.Text = string.Format(Loc("Str_St_Exported"), Path.GetFileName(dlg.FileName)); } diff --git a/Controls/ScanWorkspace/Scanning.cs b/Controls/ScanWorkspace/Scanning.cs index 7819d235..19ac606d 100644 --- a/Controls/ScanWorkspace/Scanning.cs +++ b/Controls/ScanWorkspace/Scanning.cs @@ -55,6 +55,8 @@ private async void ScanBtn_Click(object sender, RoutedEventArgs e) } _active.SubnetText = Targets; _active.ScannedSubnet = targets.Summary; + _active.ScannedAt = DateTimeOffset.Now; + _active.Completion = ScanCompletion.Partial; _active.Devices.Clear(); _topologyPositions.Clear(); var cts = new CancellationTokenSource(); @@ -89,7 +91,11 @@ private async void ScanBtn_Click(object sender, RoutedEventArgs e) } } } - catch (OperationCanceledException) { if (!_disposed) StatusText.Text = Loc("Str_St_ScanCanceled"); } + catch (OperationCanceledException) + { + _active.Completion = ScanCompletion.Canceled; + if (!_disposed) StatusText.Text = Loc("Str_St_ScanCanceled"); + } catch (Exception ex) { if (!_disposed) StatusText.Text = string.Format(Loc("Str_Err_Scan"), ex.Message); } finally { @@ -114,6 +120,8 @@ private async void ScanBtn_Click(object sender, RoutedEventArgs e) private void RecordCompletedRun(ScanCompletion completion = ScanCompletion.Complete) { var recorded = ScanHistory.Record(_active.ScannedSubnet, _active.Devices, completion); + _active.ScannedAt = recorded.Current.ScannedAt; + _active.Completion = completion; if (_disposed) return; if (PersistenceFeedback.Check(recorded.Saved, ScanHistory.Retry, () => ScanHistory.SaveError)) HistoryRecorded?.Invoke(this, EventArgs.Empty); diff --git a/Models/ScanSession.cs b/Models/ScanSession.cs index 4da872a4..f9dfdcf7 100644 --- a/Models/ScanSession.cs +++ b/Models/ScanSession.cs @@ -18,5 +18,7 @@ internal sealed class ScanSession(string subnet, NetworkScanner? scanner = null) public bool IsScanning => Cts != null; public string ScannedSubnet { get; set; } = ""; + public DateTimeOffset ScannedAt { get; set; } = DateTimeOffset.Now; + public ScanCompletion Completion { get; set; } = ScanCompletion.Partial; } } diff --git a/Services/ReportExport.cs b/Services/ReportExport.cs index 6ab0a3d1..01d0077c 100644 --- a/Services/ReportExport.cs +++ b/Services/ReportExport.cs @@ -71,6 +71,10 @@ internal static string BuildCsv(IEnumerable devices) /// lower-case ("dark", "black", ...); every app palette is embedded either way. /// internal static string BuildHtml(IEnumerable devices, string subnetText, string themeKey) + => BuildSnapshotHtml(devices, subnetText, themeKey, DateTimeOffset.Now, ScanCompletion.Complete); + + internal static string BuildSnapshotHtml(IEnumerable devices, string subnetText, string themeKey, + DateTimeOffset scannedAt, ScanCompletion completion) { string current = string.IsNullOrEmpty(themeKey) ? "dark" : themeKey.ToLowerInvariant(); var list = devices.OrderBy(d => d.IpSortKey).ToList(); @@ -146,7 +150,14 @@ internal static string BuildHtml(IEnumerable devices, string subn // Three separate words rather than one sentence with the values substituted in: the // bold values sit between them, so a language that reorders the clause still gets to. - sb.AppendLine($"

{Esc(L("Str_Rep_Subnet", "Subnet"))} {Esc(subnetText)} · {Esc(L("Str_Rep_Scanned", "Scanned"))} {DateTime.Now:yyyy-MM-dd HH:mm} · {list.Count} {Esc(L("Str_Rep_Devices", "devices"))}

"); + string state = completion switch + { + ScanCompletion.Canceled => L("Str_St_ScanCanceled", "Scan canceled"), + ScanCompletion.Partial => L("Str_History_Partial", "Partial scan"), + _ => string.Empty + }; + string status = state.Length == 0 ? string.Empty : " · " + Esc(state) + ""; + sb.AppendLine($"

{Esc(L("Str_Rep_Subnet", "Subnet"))} {Esc(subnetText)} · {Esc(L("Str_Rep_Scanned", "Scanned"))} {scannedAt:yyyy-MM-dd HH:mm} · {list.Count} {Esc(L("Str_Rep_Devices", "devices"))}{status}

"); // The report's headers reuse the grid's own column keys, so the two never disagree. sb.AppendLine("
"); diff --git a/tests/SpeedTest.Tests/ExportSnapshotTests.cs b/tests/SpeedTest.Tests/ExportSnapshotTests.cs new file mode 100644 index 00000000..4c991718 --- /dev/null +++ b/tests/SpeedTest.Tests/ExportSnapshotTests.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using KillerScan.Controls; +using KillerScan.Engine; + +internal static class ExportSnapshotTests +{ + internal static void Run() + { + string directory = Path.Combine(Path.GetTempPath(), "KillerScan-export-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + using var workspace = new ScanWorkspace(); + var scannedAt = new DateTimeOffset(2025, 3, 4, 5, 6, 0, TimeSpan.Zero); + workspace.LoadSnapshot("192.0.2.0/24", [new NetworkDevice { IpAddress = "192.0.2.1", Hostname = "original-device", OpenPorts = [80] }], scannedAt); + workspace.Targets = "198.51.100.0/24"; + string output = Path.Combine(directory, "report.html"); + workspace.ExportDialog = dialog => { dialog.FileName = output; return true; }; + workspace.Export("html"); + string report = File.ReadAllText(output); + if (!report.Contains("192.0.2.0/24") || report.Contains("198.51.100.0/24")) + throw new InvalidOperationException("The real HTML export must identify its scanned dataset even after the pending target changes."); + if (!report.Contains("2025-03-04 05:06")) + throw new InvalidOperationException("The report timestamp must belong to the captured dataset rather than the export clock."); + workspace.ExportDialog = dialog => + { + workspace.Session.Devices[0].Hostname = "changed-during-dialog"; + workspace.LoadSnapshot("203.0.113.0/24", [new NetworkDevice { IpAddress = "203.0.113.1", Hostname = "next-scan" }]); + dialog.FileName = output; + return true; + }; + workspace.Export("html"); + report = File.ReadAllText(output); + if (!report.Contains("192.0.2.0/24") || !report.Contains("original-device") || + report.Contains("203.0.113.0/24") || report.Contains("next-scan") || report.Contains("changed-during-dialog")) + throw new InvalidOperationException("Export must capture both identity and device values before the save dialog can change the live dataset."); + workspace.LoadSnapshot("192.0.2.0/24", [new NetworkDevice { IpAddress = "192.0.2.1", Hostname = "csv-before-dialog" }]); + workspace.ExportDialog = dialog => + { + workspace.Session.Devices[0].Hostname = "csv-after-dialog"; + dialog.FileName = output; + return true; + }; + workspace.Export("csv"); + string csv = File.ReadAllText(output); + if (!csv.Contains("csv-before-dialog") || csv.Contains("csv-after-dialog")) + throw new InvalidOperationException("CSV export also captures device values before opening its save dialog."); + } + finally { Directory.Delete(directory, true); } + Console.WriteLine("PASS: HTML export keeps its scanned target after pending input changes."); + } + + internal static void CheckPartial(ScanWorkspace workspace) + { + string path = Path.Combine(Path.GetTempPath(), "KillerScan-partial-" + Guid.NewGuid().ToString("N") + ".html"); + try + { + workspace.ExportDialog = dialog => { dialog.FileName = path; return true; }; + workspace.Export("html"); + if (!File.ReadAllText(path).Contains("Partial scan")) + throw new InvalidOperationException("Export during the automatic deep phase must identify its captured results as partial."); + } + finally { workspace.ExportDialog = null; if (File.Exists(path)) File.Delete(path); } + } +} diff --git a/tests/SpeedTest.Tests/ScanLifecycleTests.cs b/tests/SpeedTest.Tests/ScanLifecycleTests.cs index 9a2df2d7..7729f56a 100644 --- a/tests/SpeedTest.Tests/ScanLifecycleTests.cs +++ b/tests/SpeedTest.Tests/ScanLifecycleTests.cs @@ -57,6 +57,7 @@ internal static void Run() Require(ScanHistory.Entries.Count == 1 && !DevicePreferences.HasTrustedDevices, "A first empty scan is recorded without creating a trusted-device baseline."); CheckDeepProfile(); + ExportSnapshotTests.Run(); Console.WriteLine("PASS: populated, empty, canceled and failed scan history through the workspace command."); } @@ -116,6 +117,7 @@ private static void CheckDeepProfile() { new() { IpAddress = "192.0.2.1", OpenPorts = [80] } })); workspace.RunProfile(new ScanProfile { Target = "192.0.2.1", DeepScanAfter = true }); Require(workspace.IsScanning && ScanHistory.Entries.Count == 4, "An automatic profile remains active and unsaved while its deep phase is pending."); + ExportSnapshotTests.CheckPartial(workspace); workspace.RunProfile(new ScanProfile { Target = "192.0.2.1", DeepScanAfter = true }); workspace.Stop(); PumpUntil(() => !workspace.IsScanning); From a44a41e8cb3fb4c03c12ce6bb518ca0ed560ea0d Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:45:26 -0700 Subject: [PATCH 012/333] v1.8.4: Publish vendor database updates without truncating the prior copy --- CHANGELOG.md | 1 + Engine/Discovery/VendorDbUpdater.cs | 39 ++++++++++++- tests/KillerScan.Engine.Tests/Program.cs | 3 +- .../VendorDatabaseTests.cs | 56 +++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/KillerScan.Engine.Tests/VendorDatabaseTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 699729d2..18c5ac98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ### Fixed - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. +- Vendor database updates preserve the prior complete file when writing or replacement fails. - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. diff --git a/Engine/Discovery/VendorDbUpdater.cs b/Engine/Discovery/VendorDbUpdater.cs index 5bd1cf55..23197360 100644 --- a/Engine/Discovery/VendorDbUpdater.cs +++ b/Engine/Discovery/VendorDbUpdater.cs @@ -90,7 +90,7 @@ public readonly struct Status { var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - File.WriteAllText(path, newContent, new UTF8Encoding(false)); + PublishDatabase(path, newContent); OuiLookup.Reload(); } catch (Exception ex) @@ -101,6 +101,43 @@ public readonly struct Status return (true, OuiLookup.Count, new Status("Str_Db_Updated", OuiLookup.Count)); } + internal static void PublishDatabase(string path, string content, Action? write = null) + { + path = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + byte[] bytes = new UTF8Encoding(false).GetBytes(content); + string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + string backup = temporary + ".original"; + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + if (write == null) stream.Write(bytes, 0, bytes.Length); + else write(stream, bytes); + stream.Flush(true); + } + if (!File.ReadAllBytes(temporary).SequenceEqual(bytes)) + throw new IOException("The staged vendor database did not match the complete download."); + if (File.Exists(path)) + { + try { File.Replace(temporary, path, backup); } + catch + { + if (!File.Exists(path) && File.Exists(backup)) File.Move(backup, path); + throw; + } + TryDelete(backup); + } + else File.Move(temporary, path); + } + finally { TryDelete(temporary); } + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + private static async Task DownloadTextAsync(string url) { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index d4c1d2f4..27f55eac 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -5,10 +5,11 @@ internal static class Program { private static int _passed; - private static async Task Main() + private static async Task Main(string[] args) { try { + if (args.SequenceEqual(new[] { "--vendor-db" })) { await VendorDatabaseTests.Run(); return 0; } await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); diff --git a/tests/KillerScan.Engine.Tests/VendorDatabaseTests.cs b/tests/KillerScan.Engine.Tests/VendorDatabaseTests.cs new file mode 100644 index 00000000..ead0fdbd --- /dev/null +++ b/tests/KillerScan.Engine.Tests/VendorDatabaseTests.cs @@ -0,0 +1,56 @@ +using System.Text; +using KillerScan.Engine; + +internal static class VendorDatabaseTests +{ + internal static Task Run() + { + string directory = Path.Combine(Path.GetTempPath(), "KillerScan-vendors-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string path = Path.Combine(directory, "oui.txt"); + File.WriteAllText(path, "00000C\tOriginal vendor\n", new UTF8Encoding(false)); + byte[] original = File.ReadAllBytes(path); + bool failed = false; + try + { + VendorDbUpdater.PublishDatabase(path, "00000C\tReplacement vendor\n", (stream, bytes) => + { + stream.Write(bytes, 0, 4); + throw new IOException("Controlled partial vendor write."); + }); + } + catch (IOException) { failed = true; } + Require(failed && File.ReadAllBytes(path).SequenceEqual(original), + "A partial vendor database write must leave the prior complete file byte-identical."); + failed = false; + try { VendorDbUpdater.PublishDatabase(path, "00000C\tReplacement vendor\n", (stream, bytes) => stream.Write(bytes, 0, 4)); } + catch (IOException) { failed = true; } + Require(failed && File.ReadAllBytes(path).SequenceEqual(original), "A silently truncated sibling is rejected before publication."); + using (var locked = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + failed = false; + try { VendorDbUpdater.PublishDatabase(path, "00000C\tReplacement vendor\n"); } + catch (IOException) { failed = true; } + Require(failed && File.ReadAllBytes(path).SequenceEqual(original), "A denied rename preserves the prior vendor database."); + } + string leftover = path + ".interrupted.tmp"; + File.WriteAllText(leftover, "incomplete earlier update"); + VendorDbUpdater.PublishDatabase(path, "00000C\tReplacement vendor\n"); + Require(File.ReadAllText(path) == "00000C\tReplacement vendor\n", "Successful replacement publishes the entire new database."); + Require(File.ReadAllText(leftover) == "incomplete earlier update", "A leftover staging file is never mistaken for the database."); + string first = Path.Combine(directory, "new", "oui.txt"); + VendorDbUpdater.PublishDatabase(first, "00000C\tFirst vendor\n"); + Require(File.ReadAllText(first) == "00000C\tFirst vendor\n", "First creation publishes complete bytes into a new directory."); + Require(Directory.EnumerateFiles(directory, "*.tmp", SearchOption.AllDirectories).SequenceEqual(new[] { leftover }), + "Only the intentionally preexisting staging file remains."); + } + finally { Directory.Delete(directory, true); } + Console.WriteLine("PASS: vendor publication preserves the prior database after partial output."); + return Task.CompletedTask; + } + + private static void Require(bool condition, string message) + { if (!condition) throw new InvalidOperationException(message); } +} From d68f0f7a74240e725822c759421b7f82879e1d4a Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:51:01 -0700 Subject: [PATCH 013/333] v1.8.4: Publish complete vendor lookup snapshots during reload --- CHANGELOG.md | 2 +- Engine/Discovery/OuiLookup.cs | 101 ++++++++++++------ Engine/Discovery/VendorDbUpdater.cs | 12 ++- tests/KillerScan.Engine.Tests/Program.cs | 1 + .../VendorSnapshotTests.cs | 75 +++++++++++++ 5 files changed, 156 insertions(+), 35 deletions(-) create mode 100644 tests/KillerScan.Engine.Tests/VendorSnapshotTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 18c5ac98..2611a2e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ### Fixed - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. -- Vendor database updates preserve the prior complete file when writing or replacement fails. +- Vendor database updates preserve the prior complete file on failure and keep lookups available throughout reloads. - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. diff --git a/Engine/Discovery/OuiLookup.cs b/Engine/Discovery/OuiLookup.cs index 6ae811ba..73d5c950 100644 --- a/Engine/Discovery/OuiLookup.cs +++ b/Engine/Discovery/OuiLookup.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Reflection; +using System.Threading; namespace KillerScan.Engine { @@ -13,8 +14,35 @@ namespace KillerScan.Engine /// public static class OuiLookup { - private static readonly Dictionary OuiTable = new(StringComparer.OrdinalIgnoreCase); - private static bool _loaded; + internal static readonly object PublicationGate = new(); + private static Snapshot? _snapshot; + + private sealed class Snapshot(Dictionary table, bool external, DateTime? refreshed, bool failed) + { + internal readonly Dictionary Table = table; + internal readonly bool External = external; + internal readonly DateTime? Refreshed = refreshed; + internal readonly bool Failed = failed; + } + + private static Snapshot Current + { + get + { + var snapshot = Volatile.Read(ref _snapshot); + if (snapshot != null) return snapshot; + lock (PublicationGate) + { + snapshot = _snapshot; + if (snapshot == null) + { + snapshot = LoadCore(ExternalFilePath); + Volatile.Write(ref _snapshot, snapshot); + } + return snapshot; + } + } + } /// Writable copy that overrides the embedded list once the user refreshes it. public static string ExternalFilePath => Path.Combine( @@ -22,44 +50,57 @@ public static class OuiLookup "KillerScan", "oui.txt"); /// When the external list was last written; null when running on the bundled copy. - public static DateTime? LastRefreshed { get; private set; } + public static DateTime? LastRefreshed => Current.Refreshed; /// True when the loaded list is the user-refreshed external file rather than the bundled one. - public static bool UsingExternal { get; private set; } + public static bool UsingExternal => Current.External; /// Human-readable provenance for the About screen. - public static string LastRefreshedDisplay => - LastRefreshed.HasValue ? $"refreshed {LastRefreshed.Value:yyyy-MM-dd}" : "bundled with the app"; - - public static void Load() + public static string LastRefreshedDisplay { - if (_loaded) return; - LoadCore(); - _loaded = true; + get + { + var snapshot = Current; + return snapshot.Refreshed.HasValue ? $"refreshed {snapshot.Refreshed.Value:yyyy-MM-dd}" : "bundled with the app"; + } } + public static void Load() => _ = Current; + /// Re-reads the database from disk (used after an in-app refresh). - public static void Reload() + public static void Reload() => ReloadFrom(ExternalFilePath); + + internal static bool ReloadFrom(string externalPath) { - OuiTable.Clear(); - LoadCore(); - _loaded = true; + lock (PublicationGate) + { + var snapshot = LoadCore(externalPath); + // A failed refresh never withdraws a complete table already serving readers. + if (snapshot.Failed && _snapshot != null) return false; + Volatile.Write(ref _snapshot, snapshot); + return !snapshot.Failed; + } } - private static void LoadCore() + private static Snapshot LoadCore(string externalPath) { // Load whichever list is LARGER. A new app version can bundle a fresher database than // the user's last in-app refresh, and an upgrade should never keep serving older data // just because an external copy exists. A tie goes to the external copy, so a user // refresh that matches the bundle keeps its provenance and refresh date in About. var external = new Dictionary(StringComparer.OrdinalIgnoreCase); + bool failed = false; + DateTime? refreshed = null; try { - if (File.Exists(ExternalFilePath)) - using (var r = new StreamReader(ExternalFilePath)) - LoadFrom(r, external); + using var r = new StreamReader(externalPath); + LoadFrom(r, external); + refreshed = File.GetLastWriteTime(externalPath); + if (external.Count == 0) failed = true; } - catch { external.Clear(); } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch { external.Clear(); failed = true; } var bundled = new Dictionary(StringComparer.OrdinalIgnoreCase); try @@ -67,21 +108,21 @@ private static void LoadCore() var assembly = Assembly.GetExecutingAssembly(); var resourceName = assembly.GetManifestResourceNames() .FirstOrDefault(n => n.EndsWith("oui.txt")); - if (resourceName != null) + if (resourceName == null) failed = true; + else { using var stream = assembly.GetManifestResourceStream(resourceName); if (stream != null) using (var reader = new StreamReader(stream)) LoadFrom(reader, bundled); + else failed = true; } } - catch { bundled.Clear(); } + catch { bundled.Clear(); failed = true; } bool useExternal = external.Count > 0 && external.Count >= bundled.Count; var chosen = useExternal ? external : bundled; - foreach (var kv in chosen) OuiTable[kv.Key] = kv.Value; - UsingExternal = useExternal; - LastRefreshed = useExternal ? File.GetLastWriteTime(ExternalFilePath) : (DateTime?)null; + return new Snapshot(chosen, useExternal, useExternal ? refreshed : null, failed); } private static void LoadFrom(StreamReader reader, Dictionary table) @@ -102,18 +143,18 @@ private static void LoadFrom(StreamReader reader, Dictionary tab public static string GetVendor(string macAddress) { - if (!_loaded) Load(); + var table = Current.Table; if (string.IsNullOrEmpty(macAddress)) return string.Empty; // Normalize the MAC to raw uppercase hex, then try the most specific block first: // MA-S (36-bit / 9 hex) -> MA-M (28-bit / 7 hex) -> MA-L (24-bit / 6 hex). var hex = new string([.. macAddress.Where(Uri.IsHexDigit)]).ToUpperInvariant(); if (hex.Length < 6) return string.Empty; - if (hex.Length >= 9 && OuiTable.TryGetValue(hex[..9], out var v9)) return v9; - if (hex.Length >= 7 && OuiTable.TryGetValue(hex[..7], out var v7)) return v7; - return OuiTable.TryGetValue(hex[..6], out var v6) ? v6 : string.Empty; + if (hex.Length >= 9 && table.TryGetValue(hex[..9], out var v9)) return v9; + if (hex.Length >= 7 && table.TryGetValue(hex[..7], out var v7)) return v7; + return table.TryGetValue(hex[..6], out var v6) ? v6 : string.Empty; } - public static int Count => OuiTable.Count; + public static int Count => Current.Table.Count; } } diff --git a/Engine/Discovery/VendorDbUpdater.cs b/Engine/Discovery/VendorDbUpdater.cs index 23197360..1f05d165 100644 --- a/Engine/Discovery/VendorDbUpdater.cs +++ b/Engine/Discovery/VendorDbUpdater.cs @@ -88,10 +88,14 @@ public readonly struct Status try { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - PublishDatabase(path, newContent); - OuiLookup.Reload(); + lock (OuiLookup.PublicationGate) + { + current = OuiLookup.Count; + if (bestCount < current) + return (false, current, new Status("Str_Db_Smaller", bestCount, current)); + PublishDatabase(path, newContent); + if (!OuiLookup.ReloadFrom(path)) throw new IOException("The saved vendor database could not be loaded. The prior lookup table is still active."); + } } catch (Exception ex) { diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 27f55eac..2f320510 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -10,6 +10,7 @@ private static async Task Main(string[] args) try { if (args.SequenceEqual(new[] { "--vendor-db" })) { await VendorDatabaseTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--vendor-snapshot" })) { await VendorSnapshotTests.Run(); return 0; } await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); diff --git a/tests/KillerScan.Engine.Tests/VendorSnapshotTests.cs b/tests/KillerScan.Engine.Tests/VendorSnapshotTests.cs new file mode 100644 index 00000000..691952ba --- /dev/null +++ b/tests/KillerScan.Engine.Tests/VendorSnapshotTests.cs @@ -0,0 +1,75 @@ +using System.Text; +using KillerScan.Engine; + +internal static class VendorSnapshotTests +{ + internal static async Task Run() + { + using (var start = new ManualResetEventSlim()) + { + var firstReaders = Enumerable.Range(0, 8).Select(_ => Task.Run(() => + { + start.Wait(); + Require(OuiLookup.GetVendor("00:00:0C:11:22:33").Length > 0 && OuiLookup.Count > 10000, + "Concurrent first readers receive an initialized bundled database."); + })).ToArray(); + start.Set(); + var initialized = Task.WhenAll(firstReaders); + Require(await Task.WhenAny(initialized, Task.Delay(10000)) == initialized, "Concurrent first load completes within ten seconds."); + await initialized; + } + string directory = Path.Combine(Path.GetTempPath(), "KillerScan-vendor-snapshot-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string PathFor(string variant) + { + string path = Path.Combine(directory, variant + ".txt"); + var text = new StringBuilder(); + for (int i = 0; i < 65536; i++) text.Append((0xA00000 + i).ToString("X6")).Append('\t').Append(variant).Append('\n'); + text.Append("00000C\t").Append(variant).Append("-L\n1234567\t").Append(variant).Append("-M\n123456789\t").Append(variant).Append("-S\n"); + File.WriteAllText(path, text.ToString()); + return path; + } + string a = PathFor("A"), b = PathFor("B"); + OuiLookup.ReloadFrom(a); + string[] macs = ["00:00:0C:11:22:33", "12:34:56:70:00:00", "12:34:56:78:90:00"]; + string[] suffixes = ["-L", "-M", "-S"]; + using var stop = new CancellationTokenSource(); + int missing = 0, reads = 0; + using var started = new ManualResetEventSlim(); + var reader = Task.Run(() => + { + started.Set(); + while (!stop.IsCancellationRequested) + for (int i = 0; i < macs.Length; i++) + { + string vendor = OuiLookup.GetVendor(macs[i]); + if (vendor != "A" + suffixes[i] && vendor != "B" + suffixes[i]) Interlocked.Increment(ref missing); + Interlocked.Increment(ref reads); + } + }); + try + { + Require(started.Wait(5000), "A concurrent vendor reader starts."); + for (int i = 0; i < 6; i++) OuiLookup.ReloadFrom(i % 2 == 0 ? b : a); + } + finally { stop.Cancel(); await reader; } + Require(reads > 0 && missing == 0, "Concurrent vendor reads must see a complete old or new MA-L/MA-M/MA-S snapshot, never an empty or corrupted table. Missing=" + missing); + int count = OuiLookup.Count; + DateTime? refreshed = OuiLookup.LastRefreshed; + Require(count == 65539 && OuiLookup.UsingExternal && refreshed == File.GetLastWriteTime(a), + "The published table and its external provenance describe the same complete snapshot."); + using (var locked = new FileStream(a, FileMode.Open, FileAccess.Read, FileShare.None)) + Require(!OuiLookup.ReloadFrom(a), "A failed reload reports that the prior snapshot remains active."); + Require(OuiLookup.Count == count && OuiLookup.LastRefreshed == refreshed && OuiLookup.UsingExternal && + OuiLookup.GetVendor(macs[0]) == "A-L" && OuiLookup.GetVendor(macs[1]) == "A-M" && OuiLookup.GetVendor(macs[2]) == "A-S", + "Read failure preserves all table sizes, provenance and prefix results from the prior snapshot."); + } + finally { Directory.Delete(directory, true); } + Console.WriteLine("PASS: concurrent vendor readers see only complete published snapshots."); + } + + private static void Require(bool condition, string message) + { if (!condition) throw new InvalidOperationException(message); } +} From 8b2fffd5a3e50ca38441b8692e4e523be4cf2ba6 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:53:24 -0700 Subject: [PATCH 014/333] v1.8.4: Keep demo device types and SSH usernames in memory --- CHANGELOG.md | 1 + Services/DeviceLogins.cs | 6 +-- Services/DeviceOverrides.cs | 6 +-- tests/SpeedTest.Tests/DemoStoreTests.cs | 58 +++++++++++++++++++++++++ tests/SpeedTest.Tests/Program.cs | 1 + 5 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 tests/SpeedTest.Tests/DemoStoreTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2611a2e6..40fe2519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Vendor database updates preserve the prior complete file on failure and keep lookups available throughout reloads. - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. +- Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. diff --git a/Services/DeviceLogins.cs b/Services/DeviceLogins.cs index dd7fa788..695a532d 100644 --- a/Services/DeviceLogins.cs +++ b/Services/DeviceLogins.cs @@ -35,10 +35,10 @@ internal static class DeviceLogins public static void Load() { _store = CreateStore(); - _store.Load(); + if (!DemoData.Enabled) _store.Load(); } - public static bool Save() => _store.Save(); + public static bool Save() => DemoData.Enabled || _store.Save(); /// MAC when there is one, otherwise the IP. Empty when neither is known, which the /// callers treat as "do not remember this one". @@ -66,7 +66,7 @@ public static bool Set(string? mac, string? ip, string? user) { if (string.IsNullOrWhiteSpace(user)) values.Remove(key); else values[key] = user!.Trim(); - }); + }, DemoData.Enabled); } } } diff --git a/Services/DeviceOverrides.cs b/Services/DeviceOverrides.cs index 3d883be9..27bb1952 100644 --- a/Services/DeviceOverrides.cs +++ b/Services/DeviceOverrides.cs @@ -21,10 +21,10 @@ public static class DeviceOverrides public static void Load() { _store = CreateStore(); - _store.Load(); + if (!DemoData.Enabled) _store.Load(); } - public static bool Save() => _store.Save(); + public static bool Save() => DemoData.Enabled || _store.Save(); /// /// Set a manual override for a device by MAC address. Pass null to remove. @@ -37,7 +37,7 @@ public static bool Set(string mac, string? deviceType) { if (deviceType == null) values.Remove(key); else values[key] = deviceType; - }); + }, DemoData.Enabled); } /// diff --git a/tests/SpeedTest.Tests/DemoStoreTests.cs b/tests/SpeedTest.Tests/DemoStoreTests.cs new file mode 100644 index 00000000..121dd3a0 --- /dev/null +++ b/tests/SpeedTest.Tests/DemoStoreTests.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Reflection; +using KillerScan.Services; + +internal static class DemoStoreTests +{ + internal static void Run() + { + string directory = Path.Combine(Path.GetTempPath(), "KillerScan-demo-stores-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + bool originalDemo = DemoData.Enabled; + try + { + Check(typeof(DeviceOverrides), "overrides.json", "Printer", "Router"); + Check(typeof(DeviceLogins), "logins.json", "real-user", "demo-user"); + } + finally { DemoData.Enabled = originalDemo; Directory.Delete(directory, true); } + Console.WriteLine("PASS: demo overrides and usernames remain in memory and preserve real stores."); + + void Check(Type type, string name, string realValue, string demoValue) + { + var field = type.GetField("FilePath", BindingFlags.Static | BindingFlags.NonPublic)!; + object original = field.GetValue(null)!; + string path = Path.Combine(directory, name); + string sentinel = "{\"00:11:22:33:44:55\":\"" + realValue + "\"}"; + File.WriteAllText(path, sentinel); + try + { + field.SetValue(null, path); + DemoData.Enabled = true; + Load(); + Require(Get() == null, "Demo mode must not read the real " + name); + Require(Set(demoValue) && Get() == demoValue, "Demo edits remain available in memory."); + Require((bool)type.GetMethod("Save")!.Invoke(null, null)!, "Demo save succeeds without disk access."); + Require(File.ReadAllText(path) == sentinel, "Demo edits preserve real store bytes."); + Load(); + Require(Get() == null, "Reloading demo mode starts with an empty session."); + DemoData.Enabled = false; + Load(); + Require(Get() == realValue, "Normal mode still reads the real value."); + Require(Set("normal-change"), "Normal mode still persists edits."); + Load(); + Require(Get() == "normal-change", "Normal edits survive reload."); + } + finally { field.SetValue(null, original); DemoData.Enabled = originalDemo; } + + void Load() => type.GetMethod("Load")!.Invoke(null, null); + string? Get() => (string?)type.GetMethod("Get")!.Invoke(null, + type == typeof(DeviceOverrides) ? new object[] { "00:11:22:33:44:55" } : new object[] { "00:11:22:33:44:55", "192.0.2.1" }); + bool Set(string value) => (bool)type.GetMethod("Set")!.Invoke(null, + type == typeof(DeviceOverrides) ? new object[] { "00:11:22:33:44:55", value } : new object[] { "00:11:22:33:44:55", "192.0.2.1", value })!; + } + } + + private static void Require(bool condition, string message) + { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/tests/SpeedTest.Tests/Program.cs b/tests/SpeedTest.Tests/Program.cs index da650c8b..2e05e51c 100644 --- a/tests/SpeedTest.Tests/Program.cs +++ b/tests/SpeedTest.Tests/Program.cs @@ -31,6 +31,7 @@ private static async Task Main(string[] args) { if (args.SequenceEqual(new[] { "--history-retention" })) { HistoryRetentionTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--preference-persistence" })) { PreferencePersistenceTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--demo-stores" })) { DemoStoreTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--scan-lifecycle" })) { await HistoryUiTests.Run(lifecycleOnly: true); return 0; } if (args.Length == 4 && args[0] == "--history-writer") { HistoryRetentionTests.WriteConcurrent(args[1], args[2], args[3]); return 0; } if (args.SequenceEqual(new[] { "--scan-presentation" })) { await ScanPresentation(); return 0; } From 62128dc52b9cdcbd3eec6673e6b6b8ebab77ab17 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:54:22 -0700 Subject: [PATCH 015/333] v1.8.4: Validate IPv4 subnet input before expanding hosts --- CHANGELOG.md | 1 + Engine/Scanning/NetworkScanner.cs | 9 +++++++-- tests/KillerScan.Engine.Tests/Program.cs | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fe2519..39dd6c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. - Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. +- The public subnet helper rejects IPv6 addresses and malformed CIDR suffixes before expanding hosts. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index e8b1ae1c..2ec518d3 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -92,9 +92,14 @@ public class NetworkScanner /// public static List GetAddressesInSubnet(string cidr) { + if (string.IsNullOrWhiteSpace(cidr)) + throw new ArgumentException("An IPv4 subnet is required.", nameof(cidr)); var parts = cidr.Trim().Split('/'); - var ip = IPAddress.Parse(parts[0]); - int prefixLen = parts.Length > 1 ? int.Parse(parts[1]) : 24; + if (parts.Length > 2 || !IPAddress.TryParse(parts[0], out var ip) || ip.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("The subnet must contain an IPv4 address and an optional prefix.", nameof(cidr)); + int prefixLen = 24; + if (parts.Length == 2 && !int.TryParse(parts[1], out prefixLen)) + throw new ArgumentException("The CIDR prefix must be an integer between 0 and 32.", nameof(cidr)); if (prefixLen < 0 || prefixLen > 32) throw new ArgumentOutOfRangeException(nameof(cidr), "CIDR prefix must be between 0 and 32."); long hostCount = prefixLen >= 31 ? 0 : (1L << (32 - prefixLen)) - 2; diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 2f320510..34ca5d94 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -11,6 +11,7 @@ private static async Task Main(string[] args) { if (args.SequenceEqual(new[] { "--vendor-db" })) { await VendorDatabaseTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--vendor-snapshot" })) { await VendorSnapshotTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--subnet" })) { await Subnet(); Console.WriteLine("PASS: IPv4 subnet boundaries."); return 0; } await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); @@ -82,6 +83,15 @@ private static Task TargetCeiling() private static Task Subnet() { + foreach (string invalid in new[] { "2001:db8::/24", "::ffff:192.0.2.1/30", "2001:db8::/32", "192.0.2.0/24/30", "192.0.2.0/", "192.0.2.0/no", "", "invalid" }) + { + bool rejected = false; + try { NetworkScanner.GetAddressesInSubnet(invalid); } + catch (ArgumentException) { rejected = true; } + catch (FormatException) { rejected = true; } + Require(rejected, "Invalid or non-IPv4 subnet must be rejected: " + invalid); + } + Require(NetworkScanner.GetAddressesInSubnet("192.0.2.0/31").Count == 0, "A /31 retains the existing empty-host contract."); foreach (string cidr in new[] { "10.0.0.0/15", "0.0.0.0/0" }) { bool rejected = false; From 1fe1723df19a4cbbb7d08ca8a1b96947f0dc4833 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:56:30 -0700 Subject: [PATCH 016/333] v1.8.4: Complete OSC terminal sequences across split reads --- CHANGELOG.md | 1 + Terminal/VtParser.cs | 20 ++++++--- tests/SpeedTest.Tests/OscParserTests.cs | 58 +++++++++++++++++++++++++ tests/SpeedTest.Tests/Program.cs | 1 + 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 tests/SpeedTest.Tests/OscParserTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 39dd6c76..27f4ec88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. - Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. - The public subnet helper rejects IPv6 addresses and malformed CIDR suffixes before expanding hosts. +- Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. diff --git a/Terminal/VtParser.cs b/Terminal/VtParser.cs index 4c355332..41a1a5c9 100644 --- a/Terminal/VtParser.cs +++ b/Terminal/VtParser.cs @@ -24,7 +24,7 @@ private enum S { Ground, Escape, EscInt, CsiEntry, CsiParam, CsiInt, CsiIgnore, - OscString, + OscString, OscEscape, DcsEntry, DcsParam, DcsInt, DcsPass, DcsIgnore, SosPmApc, } @@ -38,7 +38,8 @@ private enum S private bool _parSeen; // distinguishes "CSI m" from "CSI 0 m" private char _prefix, _inter; - private readonly StringBuilder _osc = new(); + private readonly byte[] _osc = new byte[4096]; + private int _oscLength; private int _oscCmd; private bool _oscCmdDone; @@ -72,7 +73,12 @@ private void Step(byte b) } - if (b == 0x1B) { Clear(); _state = S.Escape; return; } + if (_state == S.OscEscape) + { + if (b == '\\') { FlushOsc(); return; } + _state = S.Escape; + } + if (b == 0x1B) { Clear(); _state = _state == S.OscString ? S.OscEscape : S.Escape; return; } if (b == 0x18 || b == 0x1A) { _state = S.Ground; _h.Execute(b); return; } switch (_state) @@ -85,7 +91,7 @@ private void Step(byte b) switch (b) { case (byte)'[': _state = S.CsiEntry; break; - case (byte)']': _oscCmd = 0; _oscCmdDone = false; _osc.Clear(); _state = S.OscString; break; + case (byte)']': _oscCmd = 0; _oscCmdDone = false; _oscLength = 0; _state = S.OscString; break; case (byte)'P': _state = S.DcsEntry; break; case (byte)'X': case (byte)'^': case (byte)'_': _state = S.SosPmApc; break; default: _h.EscDispatch((char)b, '\0'); _state = S.Ground; break; @@ -136,7 +142,7 @@ private void Step(byte b) if (b == ';') { _oscCmdDone = true; break; } _oscCmdDone = true; // no numeric command, treat the rest as data } - if (_osc.Length < 4096) _osc.Append((char)b); + if (_oscLength < _osc.Length) _osc[_oscLength++] = b; break; case S.DcsEntry: @@ -198,8 +204,8 @@ private void Csi(byte final) private void FlushOsc() { - _h.OscDispatch(_oscCmd, _osc.ToString()); - _osc.Clear(); + _h.OscDispatch(_oscCmd, Encoding.UTF8.GetString(_osc, 0, _oscLength)); + _oscLength = 0; _state = S.Ground; } diff --git a/tests/SpeedTest.Tests/OscParserTests.cs b/tests/SpeedTest.Tests/OscParserTests.cs new file mode 100644 index 00000000..c7ac1f2e --- /dev/null +++ b/tests/SpeedTest.Tests/OscParserTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using KillerScan.Terminal; + +internal static class OscParserTests +{ + internal static void Run() + { + foreach (string terminator in new[] { "\a", "\u001b\\" }) + foreach (string title in new[] { "fixture-title", "caf\u00e9 \u65e5\u672c \U0001f680" }) + { + byte[] bytes = Encoding.UTF8.GetBytes("\u001b]2;" + title + terminator + "visible\u001b[31m!"); + for (int split = 0; split <= bytes.Length; split++) + { + var handler = new Handler(); + var parser = new VtParser(handler); + byte[] first = bytes.Take(split).ToArray(), second = bytes.Skip(split).ToArray(); + parser.Feed(first, first.Length); parser.Feed(second, second.Length); + Require(handler.Osc.Count == 1 && handler.Osc[0] == "2:" + title, + "OSC title must dispatch once with its complete UTF-8 payload at split " + split); + Require(handler.Text.ToString() == "visible!" && handler.Csi == 1 && handler.Esc == 0, + "OSC termination returns to normal text and CSI without leaking payload or ST."); + } + } + var buffer = new TerminalBuffer(40, 5); + int titles = 0, prompts = 0; + buffer.TitleChanged += _ => titles++; + buffer.PromptReady += () => prompts++; + var live = new VtParser(buffer); + byte[] transcript = Encoding.UTF8.GetBytes("\u001b]2;device console\u001b\\\u001b]133;B\u001b\\ready"); + foreach (byte value in transcript) live.Feed(new[] { value }, 1); + Require(buffer.Title == "device console" && titles == 1 && prompts == 1, + "ST dispatch reaches the terminal title and prompt integration exactly once."); + var bounded = new Handler(); + var limitParser = new VtParser(bounded); + byte[] longTitle = Encoding.ASCII.GetBytes("\u001b]2;" + new string('x', 5000) + "\u001b\\ok"); + limitParser.Feed(longTitle, longTitle.Length); + Require(bounded.Osc.Single().Length == 4098 && bounded.Text.ToString() == "ok", "OSC payload storage stays bounded and resumes printing."); + Console.WriteLine("PASS: BEL and ST terminate OSC titles at every byte split."); + } + + private sealed class Handler : IVtHandler + { + internal readonly List Osc = new(); + internal readonly StringBuilder Text = new(); + internal int Csi, Esc; + public void Print(int codepoint) => Text.Append(char.ConvertFromUtf32(codepoint)); + public void Execute(byte control) { } + public void CsiDispatch(char final, int[] pars, char prefix, char intermediate) => Csi++; + public void EscDispatch(char final, char intermediate) => Esc++; + public void OscDispatch(int command, string data) => Osc.Add(command + ":" + data); + } + + private static void Require(bool condition, string message) + { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/tests/SpeedTest.Tests/Program.cs b/tests/SpeedTest.Tests/Program.cs index 2e05e51c..b85c9127 100644 --- a/tests/SpeedTest.Tests/Program.cs +++ b/tests/SpeedTest.Tests/Program.cs @@ -32,6 +32,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--history-retention" })) { HistoryRetentionTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--preference-persistence" })) { PreferencePersistenceTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--demo-stores" })) { DemoStoreTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--osc-parser" })) { OscParserTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--scan-lifecycle" })) { await HistoryUiTests.Run(lifecycleOnly: true); return 0; } if (args.Length == 4 && args[0] == "--history-writer") { HistoryRetentionTests.WriteConcurrent(args[1], args[2], args[3]); return 0; } if (args.SequenceEqual(new[] { "--scan-presentation" })) { await ScanPresentation(); return 0; } From f8634b94a08c6f4681fd35f60dfbf2a960f4b65f Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 14:58:27 -0700 Subject: [PATCH 017/333] v1.8.4: Cancel active HTTP and TLS fingerprint requests --- CHANGELOG.md | 1 + Engine/Scanning/NetworkScanner.cs | 34 ++++++++++++------- .../EngineBehaviorTests.cs | 22 ++++++++---- tests/KillerScan.Engine.Tests/Program.cs | 1 + 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f4ec88..6cfbfdf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. - The public subnet helper rejects IPv6 addresses and malformed CIDR suffixes before expanding hosts. - Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. +- Stopping a scan cancels active HTTP and TLS fingerprint connections and prevents probes of later ports. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 2ec518d3..9b009065 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -441,11 +441,11 @@ private async Task ProbeHostAsync(IPAddress addr, string cachedMa // -- Fingerprint probes: run in parallel, each is gated on relevant open ports -- var fpTasks = new List(); if (device.OpenPorts.Any(p => p == 80 || p == 443 || p == 8006 || p == 8080 || p == 8443 || p == 5000 || p == 5001 || p == 8123)) - fpTasks.Add(ProbeHttpAsync(device, addr)); + fpTasks.Add(ProbeHttpAsync(device, addr, ct: token)); if (device.OpenPorts.Contains(22)) fpTasks.Add(ProbeSshBannerAsync(device, addr)); if (device.OpenPorts.Any(p => p == 443 || p == 8443 || p == 8006 || p == 902)) - fpTasks.Add(ProbeTlsCertAsync(device, addr)); + fpTasks.Add(ProbeTlsCertAsync(device, addr, ct: token)); // NetBIOS + SNMP are UDP -- we probe regardless of TCP state (ports 137/161 UDP). fpTasks.Add(ProbeNetbiosAsync(device, addr)); fpTasks.Add(ProbeSnmpAsync(device, addr)); @@ -540,8 +540,8 @@ public virtual async Task DeepProbeHostAsync( var fpTasks = new List { ProbeNetbiosAsync(device, addr), ProbeSnmpAsync(device, addr) }; if (device.OpenPorts.Count > 0) { - fpTasks.Add(ProbeHttpAsync(device, addr, deep: true)); - fpTasks.Add(ProbeTlsCertAsync(device, addr, deep: true)); + fpTasks.Add(ProbeHttpAsync(device, addr, deep: true, ct: ct)); + fpTasks.Add(ProbeTlsCertAsync(device, addr, deep: true, ct: ct)); } if (device.OpenPorts.Contains(22)) fpTasks.Add(ProbeSshBannerAsync(device, addr)); @@ -943,7 +943,7 @@ void Add(string type, int s) // a web service on a non-standard port. private static readonly int[] TlsLikelyPorts = [443, 8443, 8006, 902, 5001, 9443, 4443, 10000, 8834]; - private static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, bool deep = false) + private static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, bool deep = false, CancellationToken ct = default) { (int port, bool https)[] standard = [ @@ -979,15 +979,15 @@ private static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, b foreach (var (port, https) in candidates) { + ct.ThrowIfCancellationRequested(); try { var scheme = https ? "https" : "http"; - using var resp = await client.GetAsync($"{scheme}://{addr}:{port}/"); - + using var resp = await client.GetAsync($"{scheme}://{addr}:{port}/", ct); + var body = await resp.Content.ReadAsStringAsync(); + ct.ThrowIfCancellationRequested(); if (resp.Headers.TryGetValues("Server", out var serverVals)) device.HttpServer = string.Join(", ", serverVals).Trim(); - - var body = await resp.Content.ReadAsStringAsync(); var m = Regex.Match(body, @"]*>([^<]+)", RegexOptions.IgnoreCase); if (m.Success) device.HttpTitle = WebUtility.HtmlDecode(m.Groups[1].Value).Trim(); @@ -995,8 +995,10 @@ private static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, b if (device.HttpTitle.Length > 0 || device.HttpServer.Length > 0) return; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch { } } + ct.ThrowIfCancellationRequested(); } /// @@ -1031,7 +1033,7 @@ private static async Task ProbeSshBannerAsync(NetworkDevice device, IPAddress ad /// /// Pull TLS certificate Subject from the first responsive TLS port. /// - private static async Task ProbeTlsCertAsync(NetworkDevice device, IPAddress addr, bool deep = false) + private static async Task ProbeTlsCertAsync(NetworkDevice device, IPAddress addr, bool deep = false, CancellationToken ct = default) { int[] standardTls = [443, 8443, 8006, 902, 5001]; @@ -1043,24 +1045,32 @@ private static async Task ProbeTlsCertAsync(NetworkDevice device, IPAddress addr : standardTls; foreach (var port in tlsPorts) { + ct.ThrowIfCancellationRequested(); if (!device.OpenPorts.Contains(port)) continue; try { using var tcp = new TcpClient(); + using var cancellation = ct.Register(() => tcp.Dispose()); var connect = tcp.ConnectAsync(addr, port); - if (await Task.WhenAny(connect, Task.Delay(1500)) != connect || !tcp.Connected) + if (await AwaitWithCancellation(Task.WhenAny(connect, Task.Delay(1500, ct)), ct) != connect) continue; + await connect; + ct.ThrowIfCancellationRequested(); using var ssl = new SslStream(tcp.GetStream(), false, (_, _, _, _) => true); var auth = ssl.AuthenticateAsClientAsync(addr.ToString(), null, SslProtocols.None, false); - if (await Task.WhenAny(auth, Task.Delay(1500)) != auth) continue; + if (await AwaitWithCancellation(Task.WhenAny(auth, Task.Delay(1500, ct)), ct) != auth) continue; + await auth; + ct.ThrowIfCancellationRequested(); if (ssl.RemoteCertificate != null) { device.TlsSubject = ssl.RemoteCertificate.Subject; return; } } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch { } } + ct.ThrowIfCancellationRequested(); } /// diff --git a/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs b/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs index 09545ac5..8904c63f 100644 --- a/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs +++ b/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs @@ -116,11 +116,12 @@ public static async Task DeepProbeCancellation() private static async Task FingerprintCancellation(string probeName, bool tls) { using var server = new LoopbackHttpServer(stall: true, ephemeral: true, tls: tls); + using var later = new LoopbackHttpServer(stall: false, ephemeral: true); using var cancel = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var device = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [server.Port] }; + var device = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [server.Port, later.Port] }; // Exercise the released private probes and the exact cancellation wait used by the deep // fingerprint pass. An explicit port list isolates real HTTP/TLS I/O from runner services. - var probe = StartFingerprint(probeName, device); + var probe = StartFingerprint(probeName, device, cancel.Token); var wait = typeof(NetworkScanner).GetMethods(BindingFlags.NonPublic | BindingFlags.Static) .Single(method => method.Name == "AwaitWithCancellation" && !method.IsGenericMethod); var scan = (Task)wait.Invoke(null, new object[] { Task.WhenAll(probe), cancel.Token })!; @@ -130,7 +131,11 @@ private static async Task FingerprintCancellation(string probeName, bool tls) "The real " + (tls ? "TLS ClientHello" : "HTTP GET") + " reaches the stalled fixture."); Require(!probe.IsCompleted && !scan.IsCompleted, "Fingerprinting is still waiting for the fixture response."); await RequirePromptCancellation(scan, cancel); - Require(!probe.IsCompleted, "Stop returns before the fingerprint request times out."); + Require(await Task.WhenAny(probe, Task.Delay(500)) == probe, "Stop must terminate the active fingerprint, not only its outer wait."); + Require(await Task.WhenAny(server.Disconnected, Task.Delay(500)) == server.Disconnected, + "Stop closes the active HTTP/TLS connection within 500 ms."); + await Task.Delay(100); + Require(!later.RequestReceived.IsCompleted, "Stop prevents requests to later fingerprint ports."); Require(device.HttpTitle.Length == 0 && device.HttpServer.Length == 0 && device.TlsSubject.Length == 0, "The stalled fingerprint has no completed HTTP or TLS result."); } @@ -138,14 +143,14 @@ private static async Task FingerprintCancellation(string probeName, bool tls) { cancel.Cancel(); server.Dispose(); - await probe; + try { await probe; } catch (OperationCanceledException) { } try { await scan; } catch (OperationCanceledException) { } } } - private static Task StartFingerprint(string probeName, NetworkDevice device) => + private static Task StartFingerprint(string probeName, NetworkDevice device, CancellationToken ct = default) => (Task)typeof(NetworkScanner).GetMethod(probeName, BindingFlags.NonPublic | BindingFlags.Static)! - .Invoke(null, new object[] { device, IPAddress.Loopback, true })!; + .Invoke(null, new object[] { device, IPAddress.Loopback, true, ct })!; private static async Task RequirePromptCancellation(Task scan, CancellationTokenSource cancel) { @@ -168,10 +173,12 @@ private sealed class LoopbackHttpServer : IDisposable private readonly CancellationTokenSource _stop = new(); private readonly ConcurrentBag _clients = new(); private readonly TaskCompletionSource _requested = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly bool _stall; private readonly bool _tls; public int Port { get; } public Task RequestReceived => _requested.Task; + public Task Disconnected => _disconnected.Task; public LoopbackHttpServer(bool stall, bool ephemeral = false, bool tls = false) { @@ -221,7 +228,8 @@ private async Task Respond(TcpClient client) if (_stall && (_tls ? tls : http)) { _requested.TrySetResult(true); - await Task.Delay(Timeout.Infinite, _stop.Token); + while (await stream.ReadAsync(input, 0, input.Length, _stop.Token) > 0) { } + _disconnected.TrySetResult(true); return; } if (!http) return; diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 34ca5d94..31d30271 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -12,6 +12,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--vendor-db" })) { await VendorDatabaseTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--vendor-snapshot" })) { await VendorSnapshotTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--subnet" })) { await Subnet(); Console.WriteLine("PASS: IPv4 subnet boundaries."); return 0; } + if (args.SequenceEqual(new[] { "--fingerprint-cancellation" })) { await EngineBehaviorTests.HttpFingerprintCancellation(); await EngineBehaviorTests.TlsFingerprintCancellation(); Console.WriteLine("PASS: active HTTP/TLS cancellation closes I/O and skips later ports."); return 0; } await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); From 09e6dd9cde4eb7ee3a2405d24b4f96409de60423 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:02:57 -0700 Subject: [PATCH 018/333] v1.8.4: Keep HTTP fingerprint redirects on the scanned IP --- CHANGELOG.md | 17 +-- Engine/Scanning/NetworkScanner.cs | 26 +++- .../HttpRedirectTests.cs | 120 ++++++++++++++++++ tests/KillerScan.Engine.Tests/Program.cs | 1 + 4 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 tests/KillerScan.Engine.Tests/HttpRedirectTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cfbfdf9..f452b1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,19 +12,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ### Fixed - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. +- Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. - Vendor database updates preserve the prior complete file on failure and keep lookups available throughout reloads. -- Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. +- Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. -- Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. +- Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - The public subnet helper rejects IPv6 addresses and malformed CIDR suffixes before expanding hosts. -- Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. -- Stopping a scan cancels active HTTP and TLS fingerprint connections and prevents probes of later ports. -- Executable installation verifies the complete copy and preserves recovery files if replacement fails. -- Window resize minimums stay within the current monitor's work area on small or scaled displays. -- Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. -- The shortcuts card shows shadows at its edges when more content is out of view. - A silent install or update that finds KillerScan running exits with 1618, which Intune, Configuration Manager and RMM tools retry, instead of a general failure. - Chocolatey publishing reports failure when validation is still failing after a retry. +- Window resize minimums stay within the current monitor's work area on small or scaled displays. +- The shortcuts card shows shadows at its edges when more content is out of view. +- Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. +- Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. +- Stopping a scan cancels active HTTP and TLS fingerprint connections and prevents probes of later ports. +- HTTP fingerprints follow bounded redirects only on the scanned IP, preserving local panels without contacting or labeling another device. ## [1.8.3] - 2026-10-19 diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 9b009065..93a50904 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -967,8 +967,7 @@ private static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, b using var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, _, _, _) => true, - AllowAutoRedirect = true, - MaxAutomaticRedirections = 2, + AllowAutoRedirect = false, }; using var client = new HttpClient(handler) { @@ -983,7 +982,8 @@ private static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, b try { var scheme = https ? "https" : "http"; - using var resp = await client.GetAsync($"{scheme}://{addr}:{port}/", ct); + using var resp = await GetDeviceResponseAsync(client, new Uri($"{scheme}://{addr}:{port}/"), addr, ct); + if (resp == null) continue; var body = await resp.Content.ReadAsStringAsync(); ct.ThrowIfCancellationRequested(); if (resp.Headers.TryGetValues("Server", out var serverVals)) @@ -1001,6 +1001,26 @@ private static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, b ct.ThrowIfCancellationRequested(); } + private static async Task GetDeviceResponseAsync(HttpClient client, Uri uri, IPAddress address, CancellationToken ct) + { + for (int hop = 0; hop <= 2; hop++) + { + ct.ThrowIfCancellationRequested(); + var response = await client.GetAsync(uri, ct); + int status = (int)response.StatusCode; + if (status < 300 || status >= 400) return response; + Uri? location = response.Headers.Location; + response.Dispose(); + if (hop == 2 || status is not (301 or 302 or 303 or 307 or 308) || location == null || + !Uri.TryCreate(uri, location, out var next) || + (next.Scheme != Uri.UriSchemeHttp && next.Scheme != Uri.UriSchemeHttps) || + next.UserInfo.Length != 0 || !IPAddress.TryParse(next.Host, out var target) || !target.Equals(address)) + return null; + uri = next; + } + return null; + } + /// /// Read SSH server banner (first line after TCP connect) from port 22. /// Returns strings like "SSH-2.0-OpenSSH_9.2p1" or "SSH-2.0-Cisco-1.25". diff --git a/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs b/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs new file mode 100644 index 00000000..47863dee --- /dev/null +++ b/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs @@ -0,0 +1,120 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Net.Security; +using System.Reflection; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using KillerScan.Engine; + +internal static class HttpRedirectTests +{ + internal static async Task Run() + { + using var other = new Server(IPAddress.Parse("127.0.0.2"), _ => Final()); + using (var source = new Server(IPAddress.Loopback, _ => Redirect("http://127.0.0.2:" + other.Port + "/"))) + { + var device = await Probe(source.Port); + Require(other.Requests == 0 && device.HttpTitle.Length == 0 && device.HttpServer.Length == 0, + "Off-target redirects must not send a request or attribute another device's title/header."); + } + using var same = new Server(IPAddress.Loopback, _ => Final()); + using (var source = new Server(IPAddress.Loopback, _ => Redirect("http://localhost:" + same.Port + "/"))) + { + var device = await Probe(source.Port); + Require(same.Requests == 0 && device.HttpTitle.Length == 0 && device.HttpServer.Length == 0, + "A DNS redirect cannot widen the requested literal-IP boundary."); + } + using (var source = new Server(IPAddress.Loopback, _ => Redirect("http://127.0.0.1:" + same.Port + "/panel"))) + { + var device = await Probe(source.Port); + Require(same.Requests == 1 && device.HttpTitle == "Local panel" && device.HttpServer == "Local-Fixture", + "Same-device port redirects retain the final fingerprint."); + } + using (var secure = new Server(IPAddress.Loopback, _ => Final(), tls: true)) + using (var source = new Server(IPAddress.Loopback, _ => Redirect("https://127.0.0.1:" + secure.Port + "/"))) + { + var device = await Probe(source.Port); + Require(secure.Requests == 1 && device.HttpTitle == "Local panel", "Same-IP HTTP-to-HTTPS redirects retain local panels. " + secure.Error); + } + using (var source = new Server(IPAddress.Loopback, request => request.StartsWith("GET /panel ", StringComparison.Ordinal) ? Final() : Redirect("/panel"))) + { + var device = await Probe(source.Port); + Require(source.Requests == 2 && device.HttpTitle == "Local panel", "Relative redirects remain on the scanned device."); + } + using (var source = new Server(IPAddress.Loopback, _ => Redirect("/loop"))) + { + var device = await Probe(source.Port); + Require(source.Requests == 3 && device.HttpTitle.Length == 0 && device.HttpServer.Length == 0, + "Redirect loops stop after two hops without attributing redirect content."); + } + Console.WriteLine("PASS: redirects stay on the scanned IP and retain bounded local redirects."); + } + + private static async Task Probe(int port) + { + var device = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [port] }; + await (Task)typeof(NetworkScanner).GetMethod("ProbeHttpAsync", BindingFlags.Static | BindingFlags.NonPublic)! + .Invoke(null, new object[] { device, IPAddress.Loopback, true, CancellationToken.None })!; + return device; + } + private static string Redirect(string location) => "HTTP/1.1 302 Found\r\nLocation: " + location + "\r\nServer: Redirect-Fixture\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + private static string Final() + { + const string body = "Local panel"; + return "HTTP/1.1 200 OK\r\nServer: Local-Fixture\r\nContent-Length: " + body.Length + "\r\nConnection: close\r\n\r\n" + body; + } + private sealed class Server : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _stop = new(); + private readonly ConcurrentBag _clients = new(); + private readonly Func _response; + private readonly X509Certificate2? _certificate; + private int _requests; + internal string? Error { get; private set; } + internal int Requests => Volatile.Read(ref _requests); + internal int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + internal Server(IPAddress address, Func response, bool tls = false) + { + _response = response; + if (tls) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=127.0.0.1", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1)); +#pragma warning disable SYSLIB0057 + _certificate = new X509Certificate2(certificate.Export(X509ContentType.Pfx), "", X509KeyStorageFlags.UserKeySet); +#pragma warning restore SYSLIB0057 + } + _listener = new TcpListener(address, 0); _listener.Start(); _ = Accept(); + } + private async Task Accept() + { + while (!_stop.IsCancellationRequested) + try { var client = await _listener.AcceptTcpClientAsync(); _clients.Add(client); _ = Respond(client); } + catch when (_stop.IsCancellationRequested) { return; } + } + private async Task Respond(TcpClient client) + { + using (client) + try + { + using Stream stream = _certificate == null ? client.GetStream() : new SslStream(client.GetStream()); + if (stream is SslStream ssl) await ssl.AuthenticateAsServerAsync(_certificate!, false, SslProtocols.Tls12, false); + var request = new StringBuilder(); + var bytes = new byte[4096]; + while (request.Length < 16384 && !request.ToString().Contains("\r\n\r\n")) + { int n = await stream.ReadAsync(bytes, 0, bytes.Length, _stop.Token); if (n == 0) return; request.Append(Encoding.ASCII.GetString(bytes, 0, n)); } + Interlocked.Increment(ref _requests); + byte[] output = Encoding.ASCII.GetBytes(_response(request.ToString())); + await stream.WriteAsync(output, 0, output.Length, _stop.Token); + } + catch (Exception ex) { Error = ex.ToString(); } + } + public void Dispose() { _stop.Cancel(); _listener.Stop(); foreach (var client in _clients) client.Dispose(); _certificate?.Dispose(); } + } + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 31d30271..53b12f07 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -13,6 +13,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--vendor-snapshot" })) { await VendorSnapshotTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--subnet" })) { await Subnet(); Console.WriteLine("PASS: IPv4 subnet boundaries."); return 0; } if (args.SequenceEqual(new[] { "--fingerprint-cancellation" })) { await EngineBehaviorTests.HttpFingerprintCancellation(); await EngineBehaviorTests.TlsFingerprintCancellation(); Console.WriteLine("PASS: active HTTP/TLS cancellation closes I/O and skips later ports."); return 0; } + if (args.SequenceEqual(new[] { "--http-redirects" })) { await HttpRedirectTests.Run(); return 0; } await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); From 64bfe9e86d4922b6555574f64fb5ad81c6dfd87b Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:04:18 -0700 Subject: [PATCH 019/333] v1.8.4: Verify fingerprint behavior through callable engine APIs --- Engine/Scanning/NetworkScanner.cs | 4 ++-- tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs | 12 ++++-------- tests/KillerScan.Engine.Tests/HttpRedirectTests.cs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 93a50904..49e433f5 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -943,7 +943,7 @@ void Add(string type, int s) // a web service on a non-standard port. private static readonly int[] TlsLikelyPorts = [443, 8443, 8006, 902, 5001, 9443, 4443, 10000, 8834]; - private static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, bool deep = false, CancellationToken ct = default) + public static async Task ProbeHttpAsync(NetworkDevice device, IPAddress addr, bool deep = false, CancellationToken ct = default) { (int port, bool https)[] standard = [ @@ -1053,7 +1053,7 @@ private static async Task ProbeSshBannerAsync(NetworkDevice device, IPAddress ad /// /// Pull TLS certificate Subject from the first responsive TLS port. /// - private static async Task ProbeTlsCertAsync(NetworkDevice device, IPAddress addr, bool deep = false, CancellationToken ct = default) + public static async Task ProbeTlsCertAsync(NetworkDevice device, IPAddress addr, bool deep = false, CancellationToken ct = default) { int[] standardTls = [443, 8443, 8006, 902, 5001]; diff --git a/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs b/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs index 8904c63f..9de486ae 100644 --- a/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs +++ b/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Net; using System.Net.Sockets; -using System.Reflection; using System.Text; using KillerScan.Engine; @@ -119,12 +118,9 @@ private static async Task FingerprintCancellation(string probeName, bool tls) using var later = new LoopbackHttpServer(stall: false, ephemeral: true); using var cancel = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var device = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [server.Port, later.Port] }; - // Exercise the released private probes and the exact cancellation wait used by the deep - // fingerprint pass. An explicit port list isolates real HTTP/TLS I/O from runner services. + // An explicit port list isolates real HTTP/TLS I/O from runner services. var probe = StartFingerprint(probeName, device, cancel.Token); - var wait = typeof(NetworkScanner).GetMethods(BindingFlags.NonPublic | BindingFlags.Static) - .Single(method => method.Name == "AwaitWithCancellation" && !method.IsGenericMethod); - var scan = (Task)wait.Invoke(null, new object[] { Task.WhenAll(probe), cancel.Token })!; + var scan = probe; try { Require(await Task.WhenAny(server.RequestReceived, scan, Task.Delay(5000)) == server.RequestReceived, @@ -149,8 +145,8 @@ private static async Task FingerprintCancellation(string probeName, bool tls) } private static Task StartFingerprint(string probeName, NetworkDevice device, CancellationToken ct = default) => - (Task)typeof(NetworkScanner).GetMethod(probeName, BindingFlags.NonPublic | BindingFlags.Static)! - .Invoke(null, new object[] { device, IPAddress.Loopback, true, ct })!; + probeName == "ProbeHttpAsync" ? NetworkScanner.ProbeHttpAsync(device, IPAddress.Loopback, true, ct) + : NetworkScanner.ProbeTlsCertAsync(device, IPAddress.Loopback, true, ct); private static async Task RequirePromptCancellation(Task scan, CancellationTokenSource cancel) { diff --git a/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs b/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs index 47863dee..0f4b6e50 100644 --- a/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs +++ b/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Sockets; using System.Net.Security; -using System.Reflection; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -56,8 +55,7 @@ internal static async Task Run() private static async Task Probe(int port) { var device = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [port] }; - await (Task)typeof(NetworkScanner).GetMethod("ProbeHttpAsync", BindingFlags.Static | BindingFlags.NonPublic)! - .Invoke(null, new object[] { device, IPAddress.Loopback, true, CancellationToken.None })!; + await NetworkScanner.ProbeHttpAsync(device, IPAddress.Loopback, deep: true); return device; } private static string Redirect(string location) => "HTTP/1.1 302 Found\r\nLocation: " + location + "\r\nServer: Redirect-Fixture\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; From 14788d2f889ebc7456ad9f8f99d1eb8e0f839884 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:05:16 -0700 Subject: [PATCH 020/333] v1.8.4: Isolate engine test discovery at the network transport boundary --- Engine/Discovery/IScanDiscovery.cs | 15 +++++++++++++++ Engine/Scanning/NetworkScanner.cs | 8 ++++++-- .../EngineBehaviorTests.cs | 4 ++-- .../KillerScan.Engine.Tests/IsolatedDiscovery.cs | 9 +++++++++ tests/KillerScan.Engine.Tests/Program.cs | 4 ++-- 5 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 Engine/Discovery/IScanDiscovery.cs create mode 100644 tests/KillerScan.Engine.Tests/IsolatedDiscovery.cs diff --git a/Engine/Discovery/IScanDiscovery.cs b/Engine/Discovery/IScanDiscovery.cs new file mode 100644 index 00000000..74dd1fc9 --- /dev/null +++ b/Engine/Discovery/IScanDiscovery.cs @@ -0,0 +1,15 @@ +namespace KillerScan.Engine +{ + /// Network-wide service discovery transport used during a subnet scan. + public interface IScanDiscovery + { + Task> CollectMdnsAsync(CancellationToken ct); + Task> CollectSsdpAsync(CancellationToken ct); + } + + internal sealed class MulticastScanDiscovery : IScanDiscovery + { + public Task> CollectMdnsAsync(CancellationToken ct) => MulticastDiscovery.CollectMdnsAsync(ct); + public Task> CollectSsdpAsync(CancellationToken ct) => MulticastDiscovery.CollectSsdpAsync(ct); + } +} diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 49e433f5..9950d741 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -12,6 +12,10 @@ namespace KillerScan.Engine { public class NetworkScanner { + private readonly IScanDiscovery _discovery; + public NetworkScanner() : this(new MulticastScanDiscovery()) { } + public NetworkScanner(IScanDiscovery discovery) => _discovery = discovery ?? throw new ArgumentNullException(nameof(discovery)); + // Common ports to probe for device type detection private static readonly int[] ProbePorts = [ 22, // SSH @@ -159,8 +163,8 @@ public virtual async Task> ScanSubnetAsync(string cidr, Canc // Kick off network-wide service discovery (mDNS + SSDP) to run alongside the ping sweep; // results are awaited before the probe phase and mapped to hosts by source IP. - var mdnsTask = MulticastDiscovery.CollectMdnsAsync(ct); - var ssdpTask = MulticastDiscovery.CollectSsdpAsync(ct); + var mdnsTask = _discovery.CollectMdnsAsync(ct); + var ssdpTask = _discovery.CollectSsdpAsync(ct); // Fast parallel ping sweep (async, no blocking ARP calls) var semaphore = new SemaphoreSlim(200); diff --git a/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs b/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs index 9de486ae..0edd3967 100644 --- a/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs +++ b/tests/KillerScan.Engine.Tests/EngineBehaviorTests.cs @@ -32,7 +32,7 @@ public static async Task FullScan() fingerprintAtCompletion = (device.HttpTitle, device.HttpServer); device.Hostname = "Saved device name"; }; - var scanner = new NetworkScanner(); + var scanner = new NetworkScanner(new IsolatedDiscovery()); NetworkDevice? reported = null; scanner.DeviceFound += device => reported = device; using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); @@ -90,7 +90,7 @@ public static async Task DeepProbeCancellation() } }; NetworkScanner.DeviceCompleted = _ => Interlocked.Increment(ref completionHooks); - scan = new NetworkScanner().DeepProbeHostAsync("127.0.0.1", cancel.Token); + scan = new NetworkScanner(new IsolatedDiscovery()).DeepProbeHostAsync("127.0.0.1", cancel.Token); Require(await Task.WhenAny(started.Task, Task.Delay(5000)) == started.Task, "Deep probe reaches the controlled MAC lookup."); await RequirePromptCancellation(scan, cancel); diff --git a/tests/KillerScan.Engine.Tests/IsolatedDiscovery.cs b/tests/KillerScan.Engine.Tests/IsolatedDiscovery.cs new file mode 100644 index 00000000..6637f59b --- /dev/null +++ b/tests/KillerScan.Engine.Tests/IsolatedDiscovery.cs @@ -0,0 +1,9 @@ +using KillerScan.Engine; + +internal sealed class IsolatedDiscovery : IScanDiscovery +{ + public Task> CollectMdnsAsync(CancellationToken ct) => + Task.FromResult(new Dictionary()); + public Task> CollectSsdpAsync(CancellationToken ct) => + Task.FromResult(new Dictionary()); +} diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 53b12f07..2510aa7b 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -213,7 +213,7 @@ private static async Task QuickScan() int completedHook = 0; NetworkScanner.DeviceCompleted = _ => completedHook++; - var scanner = new NetworkScanner(); + var scanner = new NetworkScanner(new IsolatedDiscovery()); var stages = new List(); var found = new List(); int lastProgress = -1; @@ -251,7 +251,7 @@ private static async Task Canceled() cts.Cancel(); try { - await new NetworkScanner().ScanSubnetAsync("127.0.0.1", cts.Token, fullScan: false); + await new NetworkScanner(new IsolatedDiscovery()).ScanSubnetAsync("127.0.0.1", cts.Token, fullScan: false); } catch (OperationCanceledException) { From affd6adf430946c89c98a2390acc57fabbc71ae5 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:07:41 -0700 Subject: [PATCH 021/333] v1.8.4: Bound native MAC lookups and cancel queued work --- CHANGELOG.md | 1 + Engine/Scanning/NetworkScanner.cs | 20 +++++- .../MacCancellationTests.cs | 72 +++++++++++++++++++ tests/KillerScan.Engine.Tests/Program.cs | 1 + .../adversarial-remediation-20261020.md | 31 ++++++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/KillerScan.Engine.Tests/MacCancellationTests.cs create mode 100644 validation/adversarial-remediation-20261020.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f452b1b0..f01ed17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. - Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. - Stopping a scan cancels active HTTP and TLS fingerprint connections and prevents probes of later ports. +- Stopping during MAC resolution returns promptly, skips queued lookups and keeps native work bounded across scans. - HTTP fingerprints follow bounded redirects only on the scanned IP, preserving local panels without contacting or labeling another device. ## [1.8.3] - 2026-10-19 diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 9950d741..5a61bc41 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -15,6 +15,7 @@ public class NetworkScanner private readonly IScanDiscovery _discovery; public NetworkScanner() : this(new MulticastScanDiscovery()) { } public NetworkScanner(IScanDiscovery discovery) => _discovery = discovery ?? throw new ArgumentNullException(nameof(discovery)); + private static readonly SemaphoreSlim NativeMacSlots = new(8); // Common ports to probe for device type detection private static readonly int[] ProbePorts = [ @@ -135,6 +136,7 @@ public static List GetAddressesInSubnet(string cidr) /// public virtual async Task> ScanSubnetAsync(string cidr, CancellationToken ct, bool fullScan = true) { + ct.ThrowIfCancellationRequested(); FlushLocalDnsCache(); // ScanTargets handles one or many comma-separated targets (CIDR, single host, or a // range) and de-duplicates overlaps. The UI validates the same string before getting @@ -217,7 +219,8 @@ public virtual async Task> ScanSubnetAsync(string cidr, Canc var addr = IPAddress.Parse(ip); string mac = discoveredHosts[ip].Mac; if (string.IsNullOrEmpty(mac)) - mac = await Task.Run(() => GetMacAddress(addr)); + mac = await ResolveMacAsync(addr, ct); + ct.ThrowIfCancellationRequested(); discoveredHosts[ip] = (addr, mac); }); await Task.WhenAll(macTasks); @@ -352,6 +355,19 @@ private static async Task ResolveVerifiedHostnameAsync(IPAddress addr) } } + private static async Task ResolveMacAsync(IPAddress address, CancellationToken ct) + { + await NativeMacSlots.WaitAsync(ct); + // A started native call owns its slot until it actually returns, even when the + // caller has canceled and a later scan is waiting for the same capacity. + var lookup = Task.Run(() => + { + try { ct.ThrowIfCancellationRequested(); return GetMacAddress(address); } + finally { NativeMacSlots.Release(); } + }); + return await AwaitWithCancellation(lookup, ct); + } + private static async Task AwaitWithCancellation(Task task, CancellationToken token) { if (task.IsCompleted) @@ -486,7 +502,7 @@ public virtual async Task DeepProbeHostAsync( { IpAddress = ip, // Fresh MAC via ARP. - MacAddress = await AwaitWithCancellation(Task.Run(() => GetMacAddress(addr), ct), ct) + MacAddress = await ResolveMacAsync(addr, ct) }; // Hostname + TTL alongside the port sweep. diff --git a/tests/KillerScan.Engine.Tests/MacCancellationTests.cs b/tests/KillerScan.Engine.Tests/MacCancellationTests.cs new file mode 100644 index 00000000..9dc5c7f4 --- /dev/null +++ b/tests/KillerScan.Engine.Tests/MacCancellationTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using KillerScan.Engine; + +internal static class MacCancellationTests +{ + internal static async Task Run() + { + var original = NetworkPlatform.Current; + var originalCompleted = NetworkScanner.DeviceCompleted; + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var platform = new BlockingPlatform(entered, release); + using var cancel = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + Task>? scan = null; + int completed = 0, found = 0; + bool macStage = false; + try + { + NetworkPlatform.Current = platform; + NetworkScanner.DeviceCompleted = _ => Interlocked.Increment(ref completed); + var scanner = new NetworkScanner(new IsolatedDiscovery()); + scanner.StatusChanged += status => { if (status.Stage == ScanStage.ResolvingMacs) macStage = true; }; + scanner.DeviceFound += _ => Interlocked.Increment(ref found); + scan = scanner.ScanSubnetAsync("127.0.0.1-20", cancel.Token, fullScan: false); + Require(await Task.Run(() => entered.Wait(5000)) && macStage, "The scan reaches blocked native MAC resolution."); + cancel.Cancel(); + Require(await Task.WhenAny(scan, Task.Delay(500)) == scan, "Stop must return while native MAC calls remain blocked."); + try { await scan; throw new InvalidOperationException("Canceled MAC scan must not return results."); } + catch (OperationCanceledException) { } + int started = platform.Calls; + Require(started is > 0 and <= 8 && platform.Active > 0, "At most eight native lookups own active work after cancellation."); + using var secondCancel = new CancellationTokenSource(); + secondCancel.Cancel(); + try { await scanner.ScanSubnetAsync("127.0.0.1-20", secondCancel.Token, false); } + catch (OperationCanceledException) { } + await Task.Delay(100); + Require(platform.Calls == started && completed == 0 && found == 0, "Canceled scans never start more native calls or publish late devices."); + release.Set(); + for (int i = 0; i < 100 && platform.Active > 0; i++) await Task.Delay(10); + Require(platform.Active == 0, "All owned native calls finish after the fixture releases them."); + await Task.Delay(100); + Require(platform.Calls == started && completed == 0 && found == 0, "Queued canceled lookups remain skipped after native capacity returns."); + var retry = await scanner.ScanSubnetAsync("127.0.0.1", CancellationToken.None, false); + Require(retry.Count == 1 && completed == 1 && found == 1, "A fresh scan works after canceled native calls finish."); + } + finally + { + cancel.Cancel(); release.Set(); + if (scan != null) try { await scan; } catch (OperationCanceledException) { } + for (int i = 0; i < 100 && platform.Active > 0; i++) await Task.Delay(10); + NetworkPlatform.Current = original; NetworkScanner.DeviceCompleted = originalCompleted; + } + Console.WriteLine("PASS: MAC cancellation returns promptly, bounds native work and skips queued calls."); + } + + private sealed class BlockingPlatform(ManualResetEventSlim entered, ManualResetEventSlim release) : INetworkPlatform + { + private int _calls, _active; + internal int Calls => Volatile.Read(ref _calls); + internal int Active => Volatile.Read(ref _active); + public IReadOnlyDictionary ReadNeighborCache() => new Dictionary(); + public string ResolveMac(IPAddress address) + { + Interlocked.Increment(ref _calls); Interlocked.Increment(ref _active); entered.Set(); + try { release.Wait(); return "00:00:0C:12:34:56"; } + finally { Interlocked.Decrement(ref _active); } + } + public void FlushDnsCache() { } + public (string Interface, string NextHop)? BestRoute(IPAddress address) => ("Loopback", ""); + } + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 2510aa7b..6ea295e6 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -14,6 +14,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--subnet" })) { await Subnet(); Console.WriteLine("PASS: IPv4 subnet boundaries."); return 0; } if (args.SequenceEqual(new[] { "--fingerprint-cancellation" })) { await EngineBehaviorTests.HttpFingerprintCancellation(); await EngineBehaviorTests.TlsFingerprintCancellation(); Console.WriteLine("PASS: active HTTP/TLS cancellation closes I/O and skips later ports."); return 0; } if (args.SequenceEqual(new[] { "--http-redirects" })) { await HttpRedirectTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--mac-cancellation" })) { await MacCancellationTests.Run(); return 0; } await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md new file mode 100644 index 00000000..26ad0aa6 --- /dev/null +++ b/validation/adversarial-remediation-20261020.md @@ -0,0 +1,31 @@ +# Adversarial remediation evidence, October 20, 2026 + +Implementation is in progress. Builds and controlled fixtures do not close rendered, native interruption, installer, Narrator or release gates. + +## ENG-004 diagnosis + +Ranked hypotheses before the controlled cancellation test: + +1. The MAC phase awaits every synchronous native lookup without a cancellation-aware wait. +2. The batch schedules every discovered host without a concurrency bound, so canceled work remains queued. +3. Discovery or hostname work, rather than MAC lookup, holds the canceled scan open. + +The fixture uses the real public subnet-scan API, loopback hosts, an isolated discovery transport and the existing platform boundary. Native resolution blocks on an owned release signal. Stage events and native-entry signals distinguish the MAC phase from discovery and name lookup. The fixture releases all blocked calls before restoring the platform. + +Red: both target frameworks failed the 500 ms caller-completion check while native lookup remained blocked. The confirmed cause is the uncancelable batch wait and unbounded native scheduling. The repair shares eight native slots across full and deep scans; a canceled caller stops waiting, queued work checks cancellation before entering native code, and each started native call releases its slot only when it returns. + +Green: the blocked-native fixture passes on net48 and net10. Caller cancellation finishes within 500 ms, at most eight native lookups remain active, a second canceled scan starts no extra calls, releasing native work does not launch canceled queued calls, no late completion events appear, and a fresh scan succeeds. The full 14-check suite also passes on both targets after this repair. + +## Current evidence + +| Finding | Local commit | Focused evidence | +| --- | --- | --- | +| ENG-002 | d68f0f7 | Concurrent first load, six reloads with MA-L/MA-M/MA-S readers, failed-reload preservation on net48/net10 | +| ENG-009 | a44a41e | Partial output, truncated staging, denied replacement, first creation and successful publication on both targets | +| UI-011 | 8b2fffd | Real-store sentinels stay unread and unchanged in demo mode; normal persistence still works | +| ENG-007 | 62128dc | IPv6 and malformed CIDR rejected; omitted /24 and empty /31-/32 retained on both targets | +| UI-010 | 1fe1723 | BEL/ST, every byte split, UTF-8 titles, prompt/title integration and bounded payload | +| ENG-005 | f8634b9 | HTTP/TLS connections close within the 500 ms fixture limit; no later candidate requests on both targets | +| ENG-006 | 09e6dd9 | No requests to another loopback IP or DNS redirect; relative, local-port and HTTP-to-HTTPS redirects retained; loops stop at two hops | + +Fingerprint fixtures now call the engine APIs directly (64bfe9e). The discovery transport boundary (14788d2) lets the complete 14-check engine suite run without LAN multicast. All 14 checks pass on both targets at that checkpoint. Production's default constructor still uses the existing multicast transport. From caa179cf2bf0c8a7d0b7bbef073674279f3946e1 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:10:55 -0700 Subject: [PATCH 022/333] v1.8.4: Prioritize recovery and data protection in release notes --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f01ed17f..4c998b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,20 +13,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. - Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. +- HTTP fingerprints follow bounded redirects only on the scanned IP, preserving local panels without contacting or labeling another device. - Vendor database updates preserve the prior complete file on failure and keep lookups available throughout reloads. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. +- Stopping a scan cancels active HTTP and TLS fingerprint connections and prevents probes of later ports. +- Stopping during MAC resolution returns promptly, skips queued lookups and keeps native work bounded across scans. - The public subnet helper rejects IPv6 addresses and malformed CIDR suffixes before expanding hosts. +- Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. - A silent install or update that finds KillerScan running exits with 1618, which Intune, Configuration Manager and RMM tools retry, instead of a general failure. - Chocolatey publishing reports failure when validation is still failing after a retry. - Window resize minimums stay within the current monitor's work area on small or scaled displays. - The shortcuts card shows shadows at its edges when more content is out of view. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. -- Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. -- Stopping a scan cancels active HTTP and TLS fingerprint connections and prevents probes of later ports. -- Stopping during MAC resolution returns promptly, skips queued lookups and keeps native work bounded across scans. -- HTTP fingerprints follow bounded redirects only on the scanned IP, preserving local panels without contacting or labeling another device. ## [1.8.3] - 2026-10-19 From 26ceb194100bde8d0dd844ff8b174073b90b64be Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:11:22 -0700 Subject: [PATCH 023/333] v1.8.4: Decode bounded SNMP responses and validate request identity --- CHANGELOG.md | 1 + Engine/Discovery/SnmpResponse.cs | 68 ++++++++++++++++++ Engine/Scanning/NetworkScanner.cs | 30 +++----- tests/KillerScan.Engine.Tests/Program.cs | 1 + tests/KillerScan.Engine.Tests/SnmpTests.cs | 72 +++++++++++++++++++ .../adversarial-remediation-20261020.md | 14 ++++ 6 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 Engine/Discovery/SnmpResponse.cs create mode 100644 tests/KillerScan.Engine.Tests/SnmpTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c998b5b..14ab9fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Executable installation verifies the complete copy and preserves recovery files if replacement fails. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. +- SNMP fingerprints preserve long descriptions and reject malformed or mismatched response identities. - Stopping a scan cancels active HTTP and TLS fingerprint connections and prevents probes of later ports. - Stopping during MAC resolution returns promptly, skips queued lookups and keeps native work bounded across scans. - The public subnet helper rejects IPv6 addresses and malformed CIDR suffixes before expanding hosts. diff --git a/Engine/Discovery/SnmpResponse.cs b/Engine/Discovery/SnmpResponse.cs new file mode 100644 index 00000000..b4c91345 --- /dev/null +++ b/Engine/Discovery/SnmpResponse.cs @@ -0,0 +1,68 @@ +using System.Text; + +namespace KillerScan.Engine +{ + internal static class SnmpResponse + { + private static readonly byte[] Community = Encoding.ASCII.GetBytes("public"); + private static readonly byte[] SysDescr = [0x2b, 6, 1, 2, 1, 1, 1, 0]; + + internal static bool TryDecode(byte[] bytes, int requestId, out string description) + { + description = string.Empty; + var packet = new Reader(bytes, 0, bytes.Length); + if (!packet.Read(0x30, out var message) || !packet.Empty || + !message.Integer(out int version) || version != 0 || !message.EqualsBytes(0x04, Community) || + !message.Read(0xa2, out var response) || !message.Empty || + !response.Integer(out int id) || id != requestId || + !response.Integer(out int error) || error != 0 || !response.Integer(out int index) || index != 0 || + !response.Read(0x30, out var bindings) || !response.Empty || + !bindings.Read(0x30, out var binding) || !bindings.Empty || + !binding.EqualsBytes(0x06, SysDescr) || !binding.Read(0x04, out var value) || !binding.Empty) + return false; + description = Encoding.UTF8.GetString(bytes, value._position, value._end - value._position).Trim(); + return true; + } + + private sealed class Reader(byte[] bytes, int start, int length) + { + internal int _position = start; + internal readonly int _end = start + length; + internal bool Empty => _position == _end; + internal bool Read(byte tag, out Reader value) + { + value = null!; + if (_end - _position < 2 || bytes[_position++] != tag) return false; + int first = bytes[_position++]; + long size = first; + if ((first & 0x80) != 0) + { + int count = first & 0x7f; + if (count == 0 || count > 4 || count > _end - _position) return false; + size = 0; + for (int i = 0; i < count; i++) size = (size << 8) | bytes[_position++]; + } + if (size > _end - _position) return false; + value = new Reader(bytes, _position, (int)size); + _position += (int)size; + return true; + } + internal bool Integer(out int result) + { + result = 0; + if (!Read(0x02, out var value)) return false; + int count = value._end - value._position; + if (count < 1 || count > 4) return false; + result = (bytes[value._position] & 0x80) == 0 ? 0 : -1; + while (value._position < value._end) result = (result << 8) | bytes[value._position++]; + return true; + } + internal bool EqualsBytes(byte tag, byte[] expected) + { + if (!Read(tag, out var value) || value._end - value._position != expected.Length) return false; + for (int i = 0; i < expected.Length; i++) if (bytes[value._position + i] != expected[i]) return false; + return true; + } + } + } +} diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 5a61bc41..a241bfde 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -5,6 +5,7 @@ using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; +using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -1166,9 +1167,9 @@ private static async Task ProbeNetbiosAsync(NetworkDevice device, IPAddress addr /// SNMPv1 GET of sysDescr.0 (1.3.6.1.2.1.1.1.0) with community "public". /// Works on most network gear, printers, and UPSes that expose SNMP. /// - private static async Task ProbeSnmpAsync(NetworkDevice device, IPAddress addr) + public static async Task ProbeSnmpAsync(NetworkDevice device, IPAddress addr) { - // Precomputed SNMPv1 GetRequest for sysDescr.0, community "public". + // SNMPv1 GetRequest for sysDescr.0, community "public". byte[] query = [ 0x30, 0x29, @@ -1184,32 +1185,23 @@ private static async Task ProbeSnmpAsync(NetworkDevice device, IPAddress addr) 0x05, 0x00, ]; + var identity = new byte[4]; + using (var random = RandomNumberGenerator.Create()) random.GetBytes(identity); + identity[0] = (byte)((identity[0] & 0x3f) | 0x40); + Array.Copy(identity, 0, query, 17, identity.Length); + int requestId = (identity[0] << 24) | (identity[1] << 16) | (identity[2] << 8) | identity[3]; try { using var udp = new UdpClient(); udp.Client.SendTimeout = 500; udp.Client.ReceiveTimeout = 1000; - await udp.SendAsync(query, query.Length, new IPEndPoint(addr, 161)); + udp.Connect(addr, 161); + await udp.SendAsync(query, query.Length); var recvTask = udp.ReceiveAsync(); if (await Task.WhenAny(recvTask, Task.Delay(1000)) != recvTask) return; var resp = recvTask.Result.Buffer; - if (resp.Length < 30) return; - - // The response's last OCTET STRING tag is sysDescr. Walk backward for - // a 0x04 tag whose payload is long enough to plausibly be a description - // and whose content doesn't match "public" (the community echo). - for (int i = resp.Length - 2; i >= 0; i--) - { - if (resp[i] != 0x04) continue; - int len = resp[i + 1]; - if (len < 5 || i + 2 + len > resp.Length) continue; - string s = Encoding.UTF8.GetString(resp, i + 2, len); - if (s.Equals("public", StringComparison.Ordinal)) continue; - if (s.Any(c => c < 0x20 && c != '\r' && c != '\n' && c != '\t')) continue; - device.SnmpDescr = s.Trim(); - return; - } + if (SnmpResponse.TryDecode(resp, requestId, out var description)) device.SnmpDescr = description; } catch { } } diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 6ea295e6..cefecf43 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -15,6 +15,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--fingerprint-cancellation" })) { await EngineBehaviorTests.HttpFingerprintCancellation(); await EngineBehaviorTests.TlsFingerprintCancellation(); Console.WriteLine("PASS: active HTTP/TLS cancellation closes I/O and skips later ports."); return 0; } if (args.SequenceEqual(new[] { "--http-redirects" })) { await HttpRedirectTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--mac-cancellation" })) { await MacCancellationTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--snmp" })) { await SnmpTests.Run(); return 0; } await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); diff --git a/tests/KillerScan.Engine.Tests/SnmpTests.cs b/tests/KillerScan.Engine.Tests/SnmpTests.cs new file mode 100644 index 00000000..95fb50fd --- /dev/null +++ b/tests/KillerScan.Engine.Tests/SnmpTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using KillerScan.Engine; + +internal static class SnmpTests +{ + internal static async Task Run() + { + foreach (int length in new[] { 33, 127, 128, 188, 255, 256, 0, 1, 3, 4 }) + { + string text = new string(Enumerable.Range(0, length).Select(i => (char)('A' + i % 26)).ToArray()); + Require(await Probe(query => Response(query, text)) == text, "Valid sysDescr length " + length + " must survive exactly."); + } + Require(await Probe(query => Response(query, "public")) == "public", "A description equal to the community is still a valid description."); + string embeddedTag = "Device \u0004\btag-like bytes remain in the actual description"; + Require(await Probe(query => Response(query, embeddedTag)) == embeddedTag, "Payload bytes are never mistaken for BER tags."); + Require(await Probe(query => Response(query, "Verified source"), wrongSource: true) == "Verified source", "Only the queried UDP source endpoint supplies the description."); + foreach (string fault in new[] { "request", "oid", "error", "index", "version", "community", "pdu", "truncated", "indefinite", "overflow", "trailing" }) + Require(await Probe(query => Response(query, "Wrong device description", fault)) == "", "Reject malformed or mismatched response: " + fault); + Console.WriteLine("PASS: bounded SNMP BER decoding preserves valid lengths and rejects wrong identities."); + } + + private static async Task Probe(Func response, bool wrongSource = false) + { + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 161)); + var request = server.ReceiveAsync(); + var device = new NetworkDevice { IpAddress = "127.0.0.1" }; + var probe = NetworkScanner.ProbeSnmpAsync(device, IPAddress.Loopback); + Require(await Task.WhenAny(request, Task.Delay(2000)) == request, "The SNMP query reaches the owned loopback fixture."); + var received = await request; + if (wrongSource) + { + using var rogue = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + byte[] injected = Response(received.Buffer, "Injected source"); + await rogue.SendAsync(injected, injected.Length, received.RemoteEndPoint); + await Task.Delay(50); + } + byte[] bytes = response(received.Buffer); + await server.SendAsync(bytes, bytes.Length, received.RemoteEndPoint); + Require(await Task.WhenAny(probe, Task.Delay(2000)) == probe, "The SNMP probe has a bounded lifetime."); + await probe; + return device.SnmpDescr; + } + + private static byte[] Response(byte[] query, string text, string fault = "") + { + byte[] id = query.Skip(17).Take(4).ToArray(); + if (fault == "request") id[3] ^= 1; + byte[] oid = [0x2b, 6, 1, 2, 1, 1, 1, 0]; + if (fault == "oid") oid[6] = 2; + byte[] value = Pack(0x04, Encoding.UTF8.GetBytes(text)); + if (fault == "indefinite") value = [0x04, 0x80, .. Encoding.UTF8.GetBytes(text), 0, 0]; + if (fault == "overflow") value = [0x04, 0x84, 0x7f, 0xff, 0xff, 0xff, .. Encoding.UTF8.GetBytes(text)]; + byte[] data = Pack(0x30, Pack(0x02, new byte[] { (byte)(fault == "version" ? 1 : 0) }), + Pack(0x04, Encoding.ASCII.GetBytes(fault == "community" ? "private" : "public")), + Pack(fault == "pdu" ? (byte)0xa0 : (byte)0xa2, Pack(0x02, id), + Pack(0x02, new byte[] { (byte)(fault == "error" ? 2 : 0) }), Pack(0x02, new byte[] { (byte)(fault == "index" ? 1 : 0) }), + Pack(0x30, Pack(0x30, Pack(0x06, oid), value)))); + if (fault == "truncated") return data.Take(data.Length - 1).ToArray(); + if (fault == "trailing") return [.. data, 0x04, 0x08, .. Encoding.ASCII.GetBytes("injected")]; + return data; + } + + private static byte[] Pack(byte tag, params byte[][] values) + { + byte[] body = values.SelectMany(value => value).ToArray(); + byte[] length = body.Length < 128 ? [(byte)body.Length] : body.Length < 256 ? [(byte)0x81, (byte)body.Length] : [(byte)0x82, (byte)(body.Length >> 8), (byte)body.Length]; + return [tag, .. length, .. body]; + } + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md index 26ad0aa6..9f27cfe5 100644 --- a/validation/adversarial-remediation-20261020.md +++ b/validation/adversarial-remediation-20261020.md @@ -16,6 +16,20 @@ Red: both target frameworks failed the 500 ms caller-completion check while nati Green: the blocked-native fixture passes on net48 and net10. Caller cancellation finishes within 500 ms, at most eight native lookups remain active, a second canceled scan starts no extra calls, releasing native work does not launch canceled queued calls, no late completion events appear, and a fresh scan succeeds. The full 14-check suite also passes on both targets after this repair. +## ENG-003 diagnosis + +Ranked hypotheses before the controlled UDP test: + +1. The backward tag search interprets a long-form BER length byte as the payload length. +2. A byte pattern inside a valid description is mistaken for a later OCTET STRING. +3. The response belongs to another request or OID, but the parser never checks that identity. + +The fixture owns loopback UDP port 161, reads the real query and replies with bounded SNMPv1 sysDescr responses. It tests the engine probe API with distinct request IDs, OIDs, error states and malformed BER containers. No real SNMP service is queried. + +Red: both targets failed exact preservation of a valid 128-byte description. The backward search treated the long-form length marker as a payload length. The decoder now validates every definite-length container, the SNMPv1 version/community, response PDU, fresh request ID, zero error/index, single sysDescr OID and OCTET STRING, with no trailing bytes. The connected UDP socket accepts only the queried source endpoint. + +Green: both targets preserve lengths 0, 1, 3, 4, 33, 127, 128, 188, 255 and 256, a description equal to the community and embedded tag-like bytes. Eleven malformed/identity variants are rejected. A forged response from a different UDP source port is ignored before the legitimate source responds. The full 14-check suite passes on both targets after the repair. + ## Current evidence | Finding | Local commit | Focused evidence | From 5bf587b1feea342b126ae2828d782d421ea27b6e Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:15:51 -0700 Subject: [PATCH 024/333] v1.8.4: Escape untrusted controls at terminal presentation boundaries --- CHANGELOG.md | 1 + Features/Cli/CliRunner.cs | 20 ++++--- Services/TerminalText.cs | 18 ++++++ Terminal/TerminalScanPresentation.cs | 3 +- tests/SpeedTest.Tests/Program.cs | 1 + tests/SpeedTest.Tests/TerminalSafetyTests.cs | 56 +++++++++++++++++++ .../adversarial-remediation-20261020.md | 14 +++++ 7 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 Services/TerminalText.cs create mode 100644 tests/SpeedTest.Tests/TerminalSafetyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ab9fce..2dbb2b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Stopping during MAC resolution returns promptly, skips queued lookups and keeps native work bounded across scans. - The public subnet helper rejects IPv6 addresses and malformed CIDR suffixes before expanding hosts. - Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. +- Terminal tables display escaped network control characters without changing raw scan data or JSON exports. - A silent install or update that finds KillerScan running exits with 1618, which Intune, Configuration Manager and RMM tools retry, instead of a general failure. - Chocolatey publishing reports failure when validation is still failing after a retry. - Window resize minimums stay within the current monitor's work area on small or scaled displays. diff --git a/Features/Cli/CliRunner.cs b/Features/Cli/CliRunner.cs index 3609dfab..48572c2f 100644 --- a/Features/Cli/CliRunner.cs +++ b/Features/Cli/CliRunner.cs @@ -88,7 +88,7 @@ private static bool IsRedirected(int stdHandle) catch { return false; } } - private static string PaintErr(string text, string code) => _errAnsi ? code + text + AnsiReset : text; + private static string PaintErr(string text, string code) => _errAnsi ? code + TerminalText.Escape(text) + AnsiReset : TerminalText.Escape(text); /// Little stderr spinner shown while a scan or probe runs on an interactive /// console. Skipped when stderr is redirected (a \r animation would be garbage in a log), @@ -216,7 +216,7 @@ private static int RunNetwork(List positionals, Dictionary 0 || options.Count > 0) return Usage(err, "/network takes no options or targets."); var net = LocalNetwork.Detect(); if (net is null) return Usage(err, "no active IPv4 network was found.", 1); - @out.WriteLine("INTERFACE " + net.InterfaceLabel); + @out.WriteLine("INTERFACE " + TerminalText.Escape(net.InterfaceLabel)); @out.WriteLine("LOCAL IP " + net.LocalIp); @out.WriteLine("SUBNET " + net.Subnet); @out.WriteLine("GATEWAY " + (net.Gateway.Length == 0 ? "-" : net.Gateway)); @@ -232,7 +232,7 @@ private static int RunVendor(List positionals, Dictionary positionals, Dictionary check.open) ? 0 : 3; @@ -598,7 +598,7 @@ private static List ApplyFilters(List devices, return descending ? [.. devices.OrderByDescending(key)] : [.. devices.OrderBy(key)]; } - private static string Render(string format, List devices, string label, + internal static string Render(string format, List devices, string label, string theme, bool noHeader) => format switch { "csv" => Csv(devices, !noHeader), @@ -607,10 +607,12 @@ private static string Render(string format, List devices, string _ => Table(devices, !noHeader), }; - private static string Table(List devices, bool header, bool ansi = false) + internal static string Table(List devices, bool header, bool ansi = false) { string[] headers = ["IP ADDRESS", "HOSTNAME", "MAC ADDRESS", "VENDOR", "TYPE", "OPEN PORTS"]; - var rows = devices.Select(d => new[] { d.IpAddress, d.Hostname, d.MacAddress, Clip(d.Vendor, 32), d.DeviceType, d.OpenPortsDisplay }).ToList(); + var rows = devices.Select(d => new[] { d.IpAddress, d.Hostname, d.MacAddress, d.Vendor, d.DeviceType, d.OpenPortsDisplay } + .Select(TerminalText.Escape).ToArray()).ToList(); + foreach (var row in rows) row[3] = Clip(row[3], 32); var width = new int[headers.Length]; for (int c = 0; c < headers.Length; c++) { width[c] = header ? headers[c].Length : 0; foreach (var row in rows) width[c] = Math.Max(width[c], (row[c] ?? "").Length); } @@ -763,7 +765,7 @@ private static (List, Dictionary, string?) ParseArgs(str private static bool IsHelp(string a) => Eq(a, "/help") || Eq(a, "--help") || Eq(a, "-h") || Eq(a, "/?") || Eq(a, "-?"); private static bool IsVersion(string a) => Eq(a, "/version") || Eq(a, "--version") || Eq(a, "-v"); private static string Clip(string? value, int max) => (value ?? "").Length <= max ? value ?? "" : value![..(max - 3)] + "..."; - private static string Flatten(string? value) => (value ?? "").Replace("\r", " ").Replace("\n", " ").Trim(); + private static string Flatten(string? value) => TerminalText.Escape(value).Trim(); private static (TextWriter, TextWriter) OpenConsole() { diff --git a/Services/TerminalText.cs b/Services/TerminalText.cs new file mode 100644 index 00000000..95f22808 --- /dev/null +++ b/Services/TerminalText.cs @@ -0,0 +1,18 @@ +using System.Text; + +namespace KillerScan.Services +{ + internal static class TerminalText + { + internal static string Escape(string? value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + var result = new StringBuilder(value!.Length); + foreach (char character in value) + if (character < 0x20 || character is >= (char)0x7f and <= (char)0x9f) + result.Append("\\x").Append(((int)character).ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); + else result.Append(character); + return result.ToString(); + } + } +} diff --git a/Terminal/TerminalScanPresentation.cs b/Terminal/TerminalScanPresentation.cs index 70af2f1e..a6084145 100644 --- a/Terminal/TerminalScanPresentation.cs +++ b/Terminal/TerminalScanPresentation.cs @@ -1,12 +1,13 @@ using System.Text; using KillerScan.Models; +using KillerScan.Services; namespace KillerScan.Terminal { internal sealed class TerminalScanPresentation(Func loc, Func columns) { private int Width => Math.Max(12, columns() - 2); - private static string Clean(string text) => new([.. text.Where(c => !char.IsControl(c))]); + private static string Clean(string text) => TerminalText.Escape(text); public string Progress(string status) { diff --git a/tests/SpeedTest.Tests/Program.cs b/tests/SpeedTest.Tests/Program.cs index b85c9127..83b2a457 100644 --- a/tests/SpeedTest.Tests/Program.cs +++ b/tests/SpeedTest.Tests/Program.cs @@ -33,6 +33,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--preference-persistence" })) { PreferencePersistenceTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--demo-stores" })) { DemoStoreTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--osc-parser" })) { OscParserTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--terminal-safety" })) { TerminalSafetyTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--scan-lifecycle" })) { await HistoryUiTests.Run(lifecycleOnly: true); return 0; } if (args.Length == 4 && args[0] == "--history-writer") { HistoryRetentionTests.WriteConcurrent(args[1], args[2], args[3]); return 0; } if (args.SequenceEqual(new[] { "--scan-presentation" })) { await ScanPresentation(); return 0; } diff --git a/tests/SpeedTest.Tests/TerminalSafetyTests.cs b/tests/SpeedTest.Tests/TerminalSafetyTests.cs new file mode 100644 index 00000000..b544ec88 --- /dev/null +++ b/tests/SpeedTest.Tests/TerminalSafetyTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using KillerScan.Engine; +using KillerScan.Features.Cli; +using KillerScan.Terminal; + +internal static class TerminalSafetyTests +{ + internal static void Run() + { + string hostile = "node\u001b[2J\u001b[H\u001b]2;spoof\a\u001b]133;KillerScan;SpeedTest\a\r\ninjected\t\u009b31m"; + var device = new NetworkDevice { IpAddress = "192.0.2.1", Hostname = hostile, MacAddress = hostile, + Vendor = hostile, DeviceType = hostile, OpenPorts = new List { 22 } }; + var devices = new List { device }; + foreach (bool ansi in new[] { false, true }) + { + string table = CliRunner.Table(devices, true, ansi); + string plain = Regex.Replace(table, "\u001b\\[(?:0|9[0-7]|1;37)m", ""); + Require(!plain.Contains('\u001b') && !plain.Contains('\u009b') && !plain.Contains('\a') && !plain.Contains('\t'), + "Untrusted controls cannot escape CLI table cells, including plain redirected output."); + Require(plain.Split(new[] { Environment.NewLine }, StringSplitOptions.None).Length == 3, + "A hostile device cannot add CLI table rows."); + Require(plain.Contains("\\x1B") && plain.Contains("\\x0A") && plain.Contains("\\x9B"), "Controls remain visible as escaped evidence."); + CheckSideEffects(table); + } + var presentation = new TerminalScanPresentation(_ => "Column", () => 512); + string embedded = presentation.Result(devices); + string visible = Regex.Replace(embedded, "\u001b\\[(?:0|3[0-7]|1;37|2K)m?", ""); + Require(!visible.Contains('\u001b') && !visible.Contains('\u009b') && !visible.Contains('\a') && !visible.Contains('\t'), "Embedded results contain only application-owned ANSI sequences."); + Require(visible.Contains("\\x1B") && visible.Contains("\\x0A") && visible.Contains("\\x9B"), "Embedded results retain escaped control evidence."); + CheckSideEffects(embedded); + string json = CliRunner.Render("json", devices, "fixture", "dark", false); + using var document = JsonDocument.Parse(json); + Require(document.RootElement[0].GetProperty("Hostname").GetString() == hostile && device.Hostname == hostile, + "Structured JSON and the underlying device preserve the original network text."); + Console.WriteLine("PASS: terminal presentation escapes controls while raw JSON preserves device text."); + + void CheckSideEffects(string output) + { + var buffer = new TerminalBuffer(1000, 10); + buffer.OscDispatch(2, "Original title"); + int actions = 0; + buffer.SpeedTestRequested += () => actions++; + var parser = new VtParser(buffer); + byte[] bytes = Encoding.UTF8.GetBytes(output); + parser.Feed(bytes, bytes.Length); + Require(buffer.Title == "Original title" && actions == 0, "Actual terminal parsing cannot execute a network-supplied title or application OSC command."); + } + } + + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md index 9f27cfe5..7e4471fd 100644 --- a/validation/adversarial-remediation-20261020.md +++ b/validation/adversarial-remediation-20261020.md @@ -30,6 +30,20 @@ Red: both targets failed exact preservation of a valid 128-byte description. The Green: both targets preserve lengths 0, 1, 3, 4, 33, 127, 128, 188, 255 and 256, a description equal to the community and embedded tag-like bytes. Eleven malformed/identity variants are rejected. A forged response from a different UDP source port is ignored before the legitimate source responds. The full 14-check suite passes on both targets after the repair. +## ENG-008 diagnosis + +Ranked hypotheses before the presentation fixture: + +1. CLI table cells carry network-supplied controls directly to the terminal. +2. ANSI coloring wraps untrusted content without escaping it first. +3. Embedded presentation follows a separate sanitation path, so fixing one sink leaves inconsistent evidence in the other. + +The fixture supplies clear-screen, cursor, OSC, BEL, CR/LF, tab and C1 bytes as device fields to the actual table/presentation APIs. It checks the allowed application ANSI sequences, row counts, visible escaped evidence and raw JSON round trips. + +Red: the CLI table emitted untrusted control bytes in both the interactive and redirected presentation route. The repair visibly encodes C0, DEL and C1 before measuring or clipping cells, shares the same escape function with embedded presentation and leaves structured data unchanged. Terminal parsing checks also verify that network text cannot change the title or dispatch the application's speed-test OSC command. + +Green: terminal-safety, narrow/wide scan-presentation and CSV-export regressions pass. Both ANSI and plain CLI tables retain their expected row count. Feeding CLI and embedded output through the real terminal parser leaves the existing title unchanged and dispatches no application command. JSON retains the exact hostile input string. + ## Current evidence | Finding | Local commit | Focused evidence | From 3fa88a32f47243656c7b832efc090c2d70452af9 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:19:28 -0700 Subject: [PATCH 025/333] v1.8.4: Add requested targets to active watches without resetting counters --- CHANGELOG.md | 1 + Controls/NetworkToolsWindow.xaml.cs | 25 ++++++-- tests/SpeedTest.Tests/ScanLifecycleTests.cs | 1 + tests/SpeedTest.Tests/WatchActionTests.cs | 59 +++++++++++++++++++ .../adversarial-remediation-20261020.md | 14 +++++ 5 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 tests/SpeedTest.Tests/WatchActionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dbb2b1e..f4cb476f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. - Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. +- Keep Alive and Diagnose add new targets to a running watch without resetting existing cards or counters. - HTTP fingerprints follow bounded redirects only on the scanned IP, preserving local panels without contacting or labeling another device. - Vendor database updates preserve the prior complete file on failure and keep lookups available throughout reloads. - Executable installation verifies the complete copy and preserves recovery files if replacement fails. diff --git a/Controls/NetworkToolsWindow.xaml.cs b/Controls/NetworkToolsWindow.xaml.cs index 41200d2d..0a2f00f2 100644 --- a/Controls/NetworkToolsWindow.xaml.cs +++ b/Controls/NetworkToolsWindow.xaml.cs @@ -57,10 +57,22 @@ internal FrameworkElement DetachToolbar() internal void IncludeTarget(string address) { - if (_run != null) return; + if (_closed) return; + if (!IPAddress.TryParse(address, out var added)) { Status.Text = L("Str_Watch_Invalid"); return; } var targets = Targets.Text.Split([',', ';', ' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - if (!targets.Contains(address)) - Targets.Text = string.Join(", ", targets.Concat([address])); + IPAddress[] existing = []; + if (targets.Length > 0 && !ConnectionChecks.TryTargets(string.Join(", ", targets), out existing)) + { Status.Text = L("Str_Watch_Invalid"); return; } + if (existing.Contains(added)) return; + if (existing.Length >= 16) { Status.Text = L("Str_Watch_Invalid"); return; } + Targets.Text = string.Join(", ", existing.Concat([added])); + if (_run is { IsCancellationRequested: false }) + { + var card = new WatchCard(added.ToString(), L("Str_Watch_Waiting")); + _cards.Add(card); + _targets.Add(new WatchTarget(added, card)); + RunChecks(card, _checks?.Token ?? _run.Token); + } } /// @@ -254,7 +266,12 @@ internal void ShowDetails(string address, IEnumerable ports) { RememberPorts(address, ports); var card = _cards.FirstOrDefault(c => c.Address == address); - if (card == null) { IncludeTarget(address); return; } + if (card == null) + { + IncludeTarget(address); + card = _cards.FirstOrDefault(c => c.Address == address); + if (card == null) return; + } Cards.SelectedItem = card; Cards.ScrollIntoView(card); } diff --git a/tests/SpeedTest.Tests/ScanLifecycleTests.cs b/tests/SpeedTest.Tests/ScanLifecycleTests.cs index 7729f56a..ba75da96 100644 --- a/tests/SpeedTest.Tests/ScanLifecycleTests.cs +++ b/tests/SpeedTest.Tests/ScanLifecycleTests.cs @@ -58,6 +58,7 @@ internal static void Run() "A first empty scan is recorded without creating a trusted-device baseline."); CheckDeepProfile(); ExportSnapshotTests.Run(); + WatchActionTests.Run(); Console.WriteLine("PASS: populated, empty, canceled and failed scan history through the workspace command."); } diff --git a/tests/SpeedTest.Tests/WatchActionTests.cs b/tests/SpeedTest.Tests/WatchActionTests.cs new file mode 100644 index 00000000..f6ab14ae --- /dev/null +++ b/tests/SpeedTest.Tests/WatchActionTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using KillerScan.Controls; +using KillerScan.Services; + +internal static class WatchActionTests +{ + internal static void Run() + { + SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher)); + DemoData.Enabled = true; + using var watch = new NetworkToolsWindow("192.0.2.1", 1); + var start = (Button)watch.FindName("StartButton"); + var stop = (Button)watch.FindName("StopButton"); + var cards = (ListBox)watch.FindName("Cards"); + var targets = (TextBox)watch.FindName("Targets"); + start.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + var first = cards.Items.OfType().Single(); + Require(first.Sent == "1" && !start.IsEnabled, "A real watch run has recorded its first sample."); + watch.IncludeTarget("192.0.2.2"); + Require(cards.Items.OfType().Any(card => card.Address == "192.0.2.2"), "Keep Alive adds a card during an active run."); + watch.ShowDetails("192.0.2.3", new[] { 8443 }); + var diagnosed = cards.Items.OfType().Single(card => card.Address == "192.0.2.3"); + Require(ReferenceEquals(cards.SelectedItem, diagnosed) && diagnosed.Checks.Any(row => row.Check == "TCP 8443"), + "Diagnose adds, selects and inspects the requested target with its known ports."); + watch.IncludeTarget("192.0.2.2"); + Require(cards.Items.Count == 3 && targets.Text.Contains("192.0.2.2") && targets.Text.Contains("192.0.2.3"), "Repeated requests are deduplicated and the target box reflects additions."); + PumpUntil(() => diagnosed.Sent != "0", TimeSpan.FromSeconds(4)); + Require(ReferenceEquals(cards.Items[0], first) && int.Parse(first.Sent) >= 2, "Existing cards and counters continue without a reset while new cards receive samples."); + for (int host = 4; host <= 16; host++) watch.IncludeTarget("192.0.2." + host); + Require(cards.Items.Count == 16, "The supported target limit remains available while watching."); + string accepted = targets.Text; + watch.IncludeTarget("192.0.2.17"); + watch.IncludeTarget("invalid"); + Require(cards.Items.Count == 16 && targets.Text == accepted && ((TextBlock)watch.FindName("Status")).Text.Length > 0, + "Unsupported additions give visible validation feedback without disturbing accepted targets."); + stop.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + PumpUntil(() => start.IsEnabled, TimeSpan.FromSeconds(1)); + Require(!stop.IsEnabled, "Stop ends the updated watch run."); + Console.WriteLine("PASS: active Keep Alive and Diagnose requests add targets without resetting existing counters."); + } + private static void PumpUntil(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!condition() && DateTime.UtcNow < deadline) + { + var frame = new DispatcherFrame(); + var timer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(10) }; + timer.Tick += (_, _) => { timer.Stop(); frame.Continue = false; }; + timer.Start(); Dispatcher.PushFrame(frame); + } + Require(condition(), "The watch command completes within its controlled timeout."); + } + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md index 7e4471fd..294afe83 100644 --- a/validation/adversarial-remediation-20261020.md +++ b/validation/adversarial-remediation-20261020.md @@ -44,6 +44,20 @@ Red: the CLI table emitted untrusted control bytes in both the interactive and r Green: terminal-safety, narrow/wide scan-presentation and CSV-export regressions pass. Both ANSI and plain CLI tables retain their expected row count. Feeding CLI and embedded output through the real terminal parser leaves the existing title unchanged and dispatches no application command. JSON retains the exact hostile input string. +## UI-005 diagnosis + +Ranked hypotheses before the watch-command fixture: + +1. IncludeTarget returns immediately for an active run, discarding the requested address. +2. ShowDetails cannot create or select a missing card and delegates to that same early return. +3. The polling loop requires a fixed target set, preventing safe additions without restarting counters. + +The controlled WPF fixture uses demo replies and diagnostics, so the actual IncludeTarget, ShowDetails and Start/Stop button commands run without network traffic. It observes cards, counters, diagnostic rows, selection and the target box through the displayed model/control boundaries. + +Red: a started watch silently ignored IncludeTarget and created no requested card. The polling loop already snapshots its mutable target list each pass, so adding a card and its target safely preserves the existing samples. New targets run their own diagnostic sweep under the existing check token and concurrency slots. Diagnose then selects the newly added card. + +Initial green: the controlled run adds and diagnoses new targets, retains existing card identity and growing counters, deduplicates repeat requests and stops through the actual button command. The integrated scan lifecycle/export fixture also passes. Extended checks cover the 16-target limit and visible rejection without changing accepted targets. + ## Current evidence | Finding | Local commit | Focused evidence | From 2892599de9338fda2d74897c0a89061bdede41d8 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:20:20 -0700 Subject: [PATCH 026/333] v1.8.4: Preserve updater recovery files and report paths --- CHANGELOG.md | 2 +- Features/About/AboutController.cs | 1 - Services/UpdateImagePublication.cs | 188 +++++++++++++++++++++++++++++ Services/UpdateService.cs | 85 +++++-------- 4 files changed, 217 insertions(+), 59 deletions(-) create mode 100644 Services/UpdateImagePublication.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f4cb476f..fc763956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Keep Alive and Diagnose add new targets to a running watch without resetting existing cards or counters. - HTTP fingerprints follow bounded redirects only on the scanned IP, preserving local panels without contacting or labeling another device. - Vendor database updates preserve the prior complete file on failure and keep lookups available throughout reloads. -- Executable installation verifies the complete copy and preserves recovery files if replacement fails. +- Executable installation and self-updates verify complete copies and preserve recovery files after replacement failures. Update reports support non-English folders. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - SNMP fingerprints preserve long descriptions and reject malformed or mismatched response identities. diff --git a/Features/About/AboutController.cs b/Features/About/AboutController.cs index 0b4c1648..edef461a 100644 --- a/Features/About/AboutController.cs +++ b/Features/About/AboutController.cs @@ -229,7 +229,6 @@ private async Task InstallAsync(string tag) } catch { - UpdateService.DiscardDownload(newExe); _updating = false; _host.UpdateEnabled = true; _host.UpdateText = string.Format(_host.Loc("Str_About_UpdateAvailable"), tag); diff --git a/Services/UpdateImagePublication.cs b/Services/UpdateImagePublication.cs new file mode 100644 index 00000000..86f211d9 --- /dev/null +++ b/Services/UpdateImagePublication.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace KillerScan.Services +{ + internal static class UpdateImagePublication + { + private static readonly ConcurrentDictionary VerifiedHashes = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + internal static void RememberVerified(string path, string sha256) + { + if (sha256.Length != 64 || Array.Exists(sha256.ToCharArray(), c => !Uri.IsHexDigit(c))) + throw new InvalidDataException("The verified update hash is invalid."); + VerifiedHashes[Path.GetFullPath(path)] = sha256; + } + + internal static string Command(string download, string target, bool elevated, + string releasesUrl, string displayName, string? version = null, string? hive = null, + string? reopen = null) + { + download = Path.GetFullPath(download); + target = Path.GetFullPath(target); + if (!VerifiedHashes.TryGetValue(download, out string? expected)) + throw new InvalidDataException("This update has no retained verification hash. Download retained at: " + download); + string staged = target + ".new-" + Guid.NewGuid().ToString("N"); + string backup = target + ".old-" + Guid.NewGuid().ToString("N"); + string restore = target + ".restore-" + Guid.NewGuid().ToString("N"); + string report = download + ".recovery.txt"; + File.WriteAllText(report, "Update publication has not been confirmed. If the helper cannot run, retain these files." + + Environment.NewLine + "Target: " + target + Environment.NewLine + "Download: " + download + + Environment.NewLine + "Staging path (not yet verified): " + staged + + Environment.NewLine + "Backup path: " + backup + Environment.NewLine + "Restoration path: " + restore + + Environment.NewLine + "Expected SHA-256: " + expected + Environment.NewLine + "Manual download: " + releasesUrl); + var script = new StringBuilder(Function); + script.AppendLine("$plan = [pscustomobject]@{"); + Value(script, "Download", download); Value(script, "Target", target); + Value(script, "Staged", staged); Value(script, "Backup", backup); + Value(script, "Restore", restore); + Value(script, "Report", report); Value(script, "Hash", expected); + Value(script, "Releases", releasesUrl); Value(script, "DisplayName", displayName); + Value(script, "Version", version ?? ""); Value(script, "Hive", hive ?? ""); + Value(script, "Reopen", reopen ?? ""); + Value(script, "Explorer", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe")); + script.AppendLine("Elevated = " + (elevated ? "$true" : "$false")).AppendLine("}"); + script.AppendLine(Operations); + script.AppendLine("if (Invoke-UpdateImagePublication $plan $ops) { exit 0 } else { exit 1 }"); + using var compressed = new MemoryStream(); + byte[] source = Encoding.UTF8.GetBytes(script.ToString()); + using (var gzip = new GZipStream(compressed, CompressionLevel.Optimal, leaveOpen: true)) + gzip.Write(source, 0, source.Length); + string bootstrap = "$stream=[IO.MemoryStream]::new([Convert]::FromBase64String('" + + Convert.ToBase64String(compressed.ToArray()) + + "'));$reader=[IO.StreamReader]::new([IO.Compression.GZipStream]::new($stream,[IO.Compression.CompressionMode]::Decompress));" + + "try{&([scriptblock]::Create($reader.ReadToEnd()))}finally{$reader.Dispose();$stream.Dispose()}"; + string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + @"WindowsPowerShell\v1.0\powershell.exe"); + string command = "\"" + powershell.Replace("%", "%%") + "\" -NoProfile -NonInteractive -Command \"" + bootstrap + "\""; + if (command.Length > 7500) + throw new IOException("The update helper command is too long. Download retained at: " + download + ". Report: " + report); + return command; + } + + private static void Value(StringBuilder script, string name, string value) => + script.AppendLine(name + " = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)) + "'))"); + + private const string Function = """ +$ErrorActionPreference = 'Stop' +function Invoke-UpdateImagePublication { + param($plan, $ops) + $stageVerified = $false + try { + $bytes = & $ops.Read $plan.Download + if ((& $ops.Hash $bytes) -ne $plan.Hash) { throw 'The download no longer matches its verified SHA-256.' } + & $ops.Stage $plan.Staged $bytes + if ((& $ops.Hash (& $ops.Read $plan.Staged)) -ne $plan.Hash) { throw 'The staged image failed SHA-256 verification.' } + $stageVerified = $true + if (& $ops.Exists $plan.Target) { & $ops.Replace $plan.Staged $plan.Target $plan.Backup } + else { & $ops.Move $plan.Staged $plan.Target } + if ((& $ops.Hash (& $ops.Read $plan.Target)) -ne $plan.Hash) { throw 'The published image failed SHA-256 verification.' } + & $ops.Metadata $plan + & $ops.Relaunch $plan + } + catch { + $failure = $_.Exception.ToString() + $restoration = '' + try { + if (!(& $ops.Exists $plan.Target) -and (& $ops.Exists $plan.Backup)) { + & $ops.Restore $plan.Backup $plan.Target $plan.Restore + } + } + catch { $restoration = [Environment]::NewLine + 'Restoration failed: ' + $_.Exception.ToString() } + $message = 'Update publication failed: ' + $failure + $restoration + [Environment]::NewLine + + 'Target: ' + $plan.Target + [Environment]::NewLine + 'Download: ' + $plan.Download + + [Environment]::NewLine + 'Staging path (verified: ' + $stageVerified + '): ' + $plan.Staged + + [Environment]::NewLine + 'Backup: ' + $plan.Backup + [Environment]::NewLine + 'Restoration path: ' + $plan.Restore + + [Environment]::NewLine + 'Report: ' + $plan.Report + [Environment]::NewLine + 'Manual download: ' + $plan.Releases + try { & $ops.Report $plan.Report $message } catch { [Console]::Error.WriteLine($message) } + return $false + } + foreach ($path in @($plan.Download, $plan.Staged, $plan.Backup, $plan.Restore, $plan.Report)) { + try { & $ops.Delete $path } catch { } + } + return $true +} + +"""; + private const string Operations = """ +$ops = @{ + Read = { param($path) ,([IO.File]::ReadAllBytes($path)) } + Hash = { param($bytes) + $hash = [Security.Cryptography.SHA256]::Create() + try { [BitConverter]::ToString($hash.ComputeHash([byte[]]$bytes)).Replace('-', '') } + finally { $hash.Dispose() } + } + Stage = { param($path, $bytes) + $stream = New-Object IO.FileStream($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, + [IO.FileShare]::None, 65536, [IO.FileOptions]::WriteThrough) + try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } + finally { $stream.Dispose() } + } + Exists = { param($path) [IO.File]::Exists($path) } + Replace = { param($source, $target, $backup) + $originalHash = & $ops.Hash (& $ops.Read $target) + $replacementHash = & $ops.Hash (& $ops.Read $source) + for ($attempt = 0; $attempt -lt 10; $attempt++) { + try { [IO.File]::Replace($source, $target, $backup); return } + catch { + $code = $_.Exception.GetBaseException().HResult -band 65535 + if ($attempt -ge 9 -or $code -notin @(32, 33)) { throw } + if (!(& $ops.Exists $source) -or !(& $ops.Exists $target) -or (& $ops.Exists $backup)) { throw } + if ((& $ops.Hash (& $ops.Read $source)) -ne $replacementHash -or + (& $ops.Hash (& $ops.Read $target)) -ne $originalHash) { throw } + Start-Sleep -Seconds 1 + if (!(& $ops.Exists $source) -or !(& $ops.Exists $target) -or (& $ops.Exists $backup)) { throw } + if ((& $ops.Hash (& $ops.Read $source)) -ne $replacementHash -or + (& $ops.Hash (& $ops.Read $target)) -ne $originalHash) { throw } + } + } + } + Move = { param($source, $target) [IO.File]::Move($source, $target) } + Restore = { param($backup, $target, $restore) + $bytes = & $ops.Read $backup + $expected = & $ops.Hash $bytes + & $ops.Stage $restore $bytes + if ((& $ops.Hash (& $ops.Read $restore)) -ne $expected) { throw 'The restoration image failed SHA-256 verification.' } + & $ops.Move $restore $target + } + Report = { param($path, $message) [IO.File]::WriteAllText($path, $message) } + Delete = { param($path) [IO.File]::Delete($path) } + Metadata = { param($plan) + if ($plan.Version.Length -eq 0 -or $plan.Hive.Length -eq 0) { return } + if ($plan.Hive -eq 'HKLM') { $root = [Microsoft.Win32.Registry]::LocalMachine } + elseif ($plan.Hive -eq 'HKCU') { $root = [Microsoft.Win32.Registry]::CurrentUser } + else { throw 'The update registry scope is invalid.' } + foreach ($keyPath in @(('Software\Microsoft\Windows\CurrentVersion\Uninstall\' + $plan.DisplayName), + ('Software\' + $plan.DisplayName))) { + $key = $root.CreateSubKey($keyPath) + try { + if ($keyPath.Contains('Uninstall')) { + $key.SetValue('DisplayVersion', $plan.Version) + $key.SetValue('Publisher', 'Steve the Killer') + } + else { $key.SetValue('Version', $plan.Version) } + } + finally { if ($null -ne $key) { $key.Dispose() } } + } + } + Relaunch = { param($plan) + $start = New-Object Diagnostics.ProcessStartInfo + $start.UseShellExecute = $true + if ($plan.Elevated) { $start.FileName = $plan.Explorer; $start.Arguments = '"' + $plan.Target + '"' } + else { + $start.FileName = $plan.Target + if ($plan.Reopen.Length -gt 0) { $start.Arguments = '"' + $plan.Reopen + '"' } + } + $process = [Diagnostics.Process]::Start($start) + if ($null -eq $process) { throw 'The updated application did not start.' } + $process.Dispose() + } +} +"""; + } +} diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs index 7e7f10c8..268b0617 100644 --- a/Services/UpdateService.cs +++ b/Services/UpdateService.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.Http; using System.Security.Cryptography; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -98,6 +99,7 @@ internal static async Task DownloadAsync(string tag) var path = Path.Combine(Path.GetTempPath(), $"{AppInfo.DisplayName}_update_{Guid.NewGuid():N}.exe"); File.WriteAllBytes(path, exeBytes); + UpdateImagePublication.RememberVerified(path, actual); return path; } @@ -119,79 +121,48 @@ internal static void StartSwap(string newExe, string? newVersion = null) bool needsElevation = !CanWriteTo(Path.GetDirectoryName(curExe)!); - // Keep Add/Remove Programs honest: the swap replaces the exe but the install keys - // still carry the old version, which is how a machine ends up with an ARP entry - // describing a version that is not on disk. The batch already runs elevated exactly - // when the target is the machine-wide install, so it is the one place that can write - // whichever hive matches. A portable exe writes nothing. - string regLines = ""; - if (!string.IsNullOrEmpty(newVersion)) - { - string hive = RegistryHiveFor(curExe); - if (hive.Length > 0) - { - string uninstall = hive + @"\Software\Microsoft\Windows\CurrentVersion\Uninstall\" + AppInfo.DisplayName; - string appKey = hive + @"\Software\" + AppInfo.DisplayName; - regLines = - $"reg add \"{uninstall}\" /v DisplayVersion /t REG_SZ /d \"{newVersion}\" /f >nul 2>&1\r\n" + - $"reg add \"{uninstall}\" /v Publisher /t REG_SZ /d \"Steve the Killer\" /f >nul 2>&1\r\n" + - $"reg add \"{appKey}\" /v Version /t REG_SZ /d \"{newVersion}\" /f >nul 2>&1\r\n"; - } - } - - // When elevated, relaunch through explorer.exe so the app comes back at the user's - // normal integrity level instead of inheriting the elevated token. - string relaunch = needsElevation - ? $"start \"\" explorer.exe \"{curExe}\"" - : $"start \"\" \"{curExe}\""; - - string staged = curExe + "." + Guid.NewGuid().ToString("N") + ".new"; - string log = Path.ChangeExtension(bat, ".log"); + string system = Environment.GetFolderPath(Environment.SpecialFolder.System); + string tasklist = Path.Combine(system, "tasklist.exe").Replace("%", "%%"); + string find = Path.Combine(system, "find.exe").Replace("%", "%%"); + string ping = Path.Combine(system, "ping.exe").Replace("%", "%%"); + string notepad = Convert.ToBase64String(Encoding.UTF8.GetBytes(Path.Combine(system, "notepad.exe"))); + string report = Convert.ToBase64String(Encoding.UTF8.GetBytes(Path.GetFullPath(newExe) + ".recovery.txt")); + string displayScript = "$ErrorActionPreference='Stop';$start=New-Object Diagnostics.ProcessStartInfo;" + + "$start.UseShellExecute=$true;$start.FileName=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + notepad + "'));" + + "$report=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + report + "'));" + + "$start.Arguments='\"'+$report+'\"';$process=[Diagnostics.Process]::Start($start);" + + "if($null -eq $process){throw 'The update recovery report did not open.'};$process.Dispose();"; + string displayPowerShell = Path.Combine(system, @"WindowsPowerShell\v1.0\powershell.exe").Replace("%", "%%"); + string displayCommand = "\"" + displayPowerShell + "\" -NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(displayScript)); + string publication = UpdateImagePublication.Command(newExe, curExe, needsElevation, + ReleasesUrl, AppInfo.DisplayName, newVersion, RegistryHiveFor(curExe)); File.WriteAllText(bat, "@echo off\r\n" + - "setlocal\r\n" + + "setlocal DisableDelayedExpansion\r\n" + ":wait\r\n" + - $"tasklist /fi \"PID eq {pid}\" 2>nul | find \"{pid}\" >nul\r\n" + - "if not errorlevel 1 ( ping -n 2 127.0.0.1 >nul & goto wait )\r\n" + - "set \"failure=staging the download\"\r\n" + - $"copy /b /y \"{newExe}\" \"{staged}\" >\"{log}\" 2>&1\r\n" + - "if errorlevel 1 goto failed\r\n" + - "set \"failure=verifying the staged file\"\r\n" + - $"fc /b \"{newExe}\" \"{staged}\" >>\"{log}\" 2>&1\r\n" + - "if errorlevel 1 goto failed\r\n" + - "set \"failure=replacing the installed executable\"\r\n" + - "for /l %%i in (1,1,10) do (\r\n" + - $" move /y \"{staged}\" \"{curExe}\" >>\"{log}\" 2>&1\r\n" + - " if not errorlevel 1 goto replaced\r\n" + - " timeout /t 1 /nobreak >nul\r\n" + - ")\r\n" + - "goto failed\r\n" + - ":replaced\r\n" + - "set \"failure=verifying the installed executable\"\r\n" + - $"fc /b \"{newExe}\" \"{curExe}\" >>\"{log}\" 2>&1\r\n" + + $"\"{tasklist}\" /fi \"PID eq {pid}\" 2>nul | \"{find}\" \"{pid}\" >nul\r\n" + + $"if not errorlevel 1 ( \"{ping}\" -n 2 127.0.0.1 >nul & goto wait )\r\n" + + publication + " >\"%~dpn0.helper.log\" 2>&1\r\n" + "if errorlevel 1 goto failed\r\n" + - regLines + - relaunch + "\r\n" + - $"del \"{log}\" >nul 2>&1\r\n" + "goto cleanup\r\n" + ":failed\r\n" + - $"echo KillerScan could not finish its update while %failure%. >>\"{log}\"\r\n" + - $"echo Check the app version before retrying. Manual download: {ReleasesUrl} >>\"{log}\"\r\n" + - $"start \"\" notepad.exe \"{log}\"\r\n" + + displayCommand + " >\"%~dpn0.display.log\" 2>&1\r\n" + ":cleanup\r\n" + - $"del \"{staged}\" >nul 2>&1\r\n" + - $"del \"{newExe}\" >nul 2>&1\r\n" + "del \"%~f0\" >nul 2>&1\r\n"); - var psi = new ProcessStartInfo("cmd.exe", $"/c \"{bat}\"") + var psi = new ProcessStartInfo( + Path.Combine(system, "cmd.exe"), $"/d /v:off /c \"{Path.GetFileName(bat)}\"") { WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = Path.GetTempPath(), UseShellExecute = true }; if (needsElevation) psi.Verb = "runas"; // triggers the UAC prompt - Process.Start(psi); + if (Process.Start(psi) is null) + throw new IOException("The update helper did not start. Download retained at: " + newExe); } /// "HKCU" when is the per-user install, From 65762529961e14eedd02d1297e65762d65e255af Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:24:25 -0700 Subject: [PATCH 027/333] v1.8.4: Route export actions through the active view context --- CHANGELOG.md | 1 + Controls/ScanWorkspace/Export.cs | 67 +++++++++++------ Controls/ScanWorkspace/Shortcuts.cs | 7 +- Controls/ScanWorkspace/Snapshot.cs | 6 +- Controls/ScanWorkspace/Topology.cs | 5 +- Shell/Workspace.cs | 6 -- tests/SpeedTest.Tests/ExportActionTests.cs | 74 +++++++++++++++++++ tests/SpeedTest.Tests/ScanLifecycleTests.cs | 1 + .../adversarial-remediation-20261020.md | 14 ++++ 9 files changed, 142 insertions(+), 39 deletions(-) create mode 100644 tests/SpeedTest.Tests/ExportActionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fc763956..061a8ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Vendor database updates preserve the prior complete file on failure and keep lookups available throughout reloads. - Executable installation and self-updates verify complete copies and preserve recovery files after replacement failures. Update reports support non-English folders. - Reports keep the scanned target, timestamp and captured results when pending input or live results change during export. +- Export menus, shortcuts and tooltips match the active view, with one filtered CSV action for Services. - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - SNMP fingerprints preserve long descriptions and reject malformed or mismatched response identities. - Stopping a scan cancels active HTTP and TLS fingerprint connections and prevents probes of later ports. diff --git a/Controls/ScanWorkspace/Export.cs b/Controls/ScanWorkspace/Export.cs index 1e4cf179..4a404169 100644 --- a/Controls/ScanWorkspace/Export.cs +++ b/Controls/ScanWorkspace/Export.cs @@ -2,6 +2,8 @@ using System.IO; using System.Text; using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; using System.Windows.Media; using System.Windows.Media.Imaging; using KillerScan.Controls; @@ -31,8 +33,24 @@ public string ExportContext } } - private void UpdateExportButtonAvailability() => + private void UpdateExportButtonAvailability() + { ExportButton.IsEnabled = _exportContext is "watch" or "terminal" or "history" || ActiveDevices.Count > 0; + string shortcuts = CurrentExportContext() switch + { + "services" or "history" => "CSV Ctrl+E", + "watch" => "CSV Ctrl+E, HTML Ctrl+Shift+E, TXT Ctrl+Shift+X, PNG Ctrl+Shift+G", + "terminal" => "TXT Ctrl+Shift+X", + "topology" => "PNG Ctrl+Shift+G, JPG Ctrl+Shift+J, SVG Ctrl+Alt+H", + _ => "CSV Ctrl+E, HTML Ctrl+Shift+E" + }; + var tooltip = new TextBlock(); + var caption = new Run(); + caption.SetResourceReference(Run.TextProperty, "Str_TT_Export"); + tooltip.Inlines.Add(caption); + tooltip.Inlines.Add(" (" + shortcuts + ")"); + ExportButton.ToolTip = tooltip; + } private string CurrentExportContext() => _exportContext == "scan" ? (_showTopology ? "topology" : _showServices ? "services" : "devices") @@ -41,11 +59,14 @@ private string CurrentExportContext() => _exportContext == "scan" /// Raised for the exports the shell owns: the Keep Alive run and the terminal. public event EventHandler? ShellExportRequested; - private void ExportWatchCsv_Click(object sender, RoutedEventArgs e) => ShellExportRequested?.Invoke(this, "watch-csv"); - private void ExportWatchHtml_Click(object sender, RoutedEventArgs e) => ShellExportRequested?.Invoke(this, "watch-html"); - private void ExportWatchTxt_Click(object sender, RoutedEventArgs e) => ShellExportRequested?.Invoke(this, "watch-txt"); - private void ExportWatchPng_Click(object sender, RoutedEventArgs e) => ShellExportRequested?.Invoke(this, "watch-png"); - private void ExportTerminalText_Click(object sender, RoutedEventArgs e) => ShellExportRequested?.Invoke(this, "terminal-txt"); + private void ExportCsv_Click(object sender, RoutedEventArgs e) => Export("csv"); + private void ExportServicesCsv_Click(object sender, RoutedEventArgs e) => Export("csv"); + private void ExportHtml_Click(object sender, RoutedEventArgs e) => Export("html"); + private void ExportWatchCsv_Click(object sender, RoutedEventArgs e) => Export("csv"); + private void ExportWatchHtml_Click(object sender, RoutedEventArgs e) => Export("html"); + private void ExportWatchTxt_Click(object sender, RoutedEventArgs e) => Export("txt"); + private void ExportWatchPng_Click(object sender, RoutedEventArgs e) => Export("png"); + private void ExportTerminalText_Click(object sender, RoutedEventArgs e) => Export("txt"); public void Export(string format) { @@ -53,18 +74,18 @@ public void Export(string format) switch (CurrentExportContext(), format) { - case ("devices", "csv"): ExportCsv_Click(this, new RoutedEventArgs()); break; - case ("services", "csv"): ExportServicesCsv_Click(this, new RoutedEventArgs()); break; + case ("devices", "csv"): WriteDevicesCsv(); break; + case ("services", "csv"): WriteServicesCsv(); break; case ("history", "csv"): ShellExportRequested?.Invoke(this, "history-csv"); break; - case ("watch", "csv"): ExportWatchCsv_Click(this, new RoutedEventArgs()); break; - case ("devices", "html"): ExportHtml_Click(this, new RoutedEventArgs()); break; - case ("watch", "html"): ExportWatchHtml_Click(this, new RoutedEventArgs()); break; - case ("watch", "txt"): ExportWatchTxt_Click(this, new RoutedEventArgs()); break; - case ("terminal", "txt"): ExportTerminalText_Click(this, new RoutedEventArgs()); break; - case ("watch", "png"): ExportWatchPng_Click(this, new RoutedEventArgs()); break; - case ("topology", "png"): ExportSnapshotPngAlpha_Click(this, new RoutedEventArgs()); break; - case ("topology", "jpg"): ExportSnapshotJpeg_Click(this, new RoutedEventArgs()); break; - case ("topology", "svg"): ExportSnapshotSvg_Click(this, new RoutedEventArgs()); break; + case ("watch", "csv"): ShellExportRequested?.Invoke(this, "watch-csv"); break; + case ("devices", "html"): WriteDevicesHtml(); break; + case ("watch", "html"): ShellExportRequested?.Invoke(this, "watch-html"); break; + case ("watch", "txt"): ShellExportRequested?.Invoke(this, "watch-txt"); break; + case ("terminal", "txt"): ShellExportRequested?.Invoke(this, "terminal-txt"); break; + case ("watch", "png"): ShellExportRequested?.Invoke(this, "watch-png"); break; + case ("topology", "png"): ExportSnapshot(SnapshotFormat.PngTransparent); break; + case ("topology", "jpg"): ExportSnapshot(SnapshotFormat.Jpeg); break; + case ("topology", "svg"): ExportSnapshot(SnapshotFormat.Svg); break; } } @@ -83,10 +104,8 @@ private void ExportButton_Click(object sender, RoutedEventArgs e) string context = CurrentExportContext(); static Visibility When(bool shown) => shown ? Visibility.Visible : Visibility.Collapsed; - bool table = context is "devices" or "services"; - - ExportCsvItem.Visibility = When(table); - ExportHtmlItem.Visibility = When(table); + ExportCsvItem.Visibility = When(context == "devices"); + ExportHtmlItem.Visibility = When(context == "devices"); ExportServicesCsvItem.Visibility = When(context == "services"); ExportTopologyPngItem.Visibility = When(context == "topology"); ExportTopologyJpegItem.Visibility = When(context == "topology"); @@ -103,7 +122,7 @@ private void ExportButton_Click(object sender, RoutedEventArgs e) ExportButton.ContextMenu.IsOpen = true; } - private void ExportCsv_Click(object sender, RoutedEventArgs e) + private void WriteDevicesCsv() { var dlg = new FileDialog(FileDialogMode.Save) { @@ -125,7 +144,7 @@ private void ExportCsv_Click(object sender, RoutedEventArgs e) /// The service view's own export: one row per open port, as the grid shows it, rather /// than one row per device with the ports packed into a single cell. /// - private void ExportServicesCsv_Click(object sender, RoutedEventArgs e) + private void WriteServicesCsv() { var dlg = new FileDialog(FileDialogMode.Save) { @@ -149,7 +168,7 @@ private void ExportServicesCsv_Click(object sender, RoutedEventArgs e) { MessageBox.Show(string.Format(Loc("Str_Err_Export"), ex.Message), AppInfo.DisplayName, MessageBoxButton.OK, MessageBoxImage.Error); } } - private void ExportHtml_Click(object sender, RoutedEventArgs e) + private void WriteDevicesHtml() { var dlg = new FileDialog(FileDialogMode.Save) { diff --git a/Controls/ScanWorkspace/Shortcuts.cs b/Controls/ScanWorkspace/Shortcuts.cs index de3654d7..05143a25 100644 --- a/Controls/ScanWorkspace/Shortcuts.cs +++ b/Controls/ScanWorkspace/Shortcuts.cs @@ -61,11 +61,8 @@ public void HandleKey(KeyEventArgs e) else if (key == Key.Escape && IsScanning) action = Stop; else if (ctrl && shift && !alt && key == Key.F) action = FocusFilter; else if (ctrl && !shift && !alt && key == Key.F) action = FocusTargets; - else if (ctrl && !shift && !alt && key == Key.E) action = () => - { - if (_showServices) ExportServicesCsv_Click(this, new RoutedEventArgs()); - else ExportCsv_Click(this, new RoutedEventArgs()); - }; + else if (ctrl && !shift && !alt && key == Key.E) action = () => Export("csv"); + else if (ctrl && shift && !alt && key == Key.E) action = () => Export("html"); else if (ctrl && !shift && !alt && key == Key.R) action = RescanSelected; // Ctrl+G, not an F key: arranging the topology is a thing you do repeatedly while // looking at it, so it sits with the other Ctrl chords rather than up on the F row. diff --git a/Controls/ScanWorkspace/Snapshot.cs b/Controls/ScanWorkspace/Snapshot.cs index 91daf9b5..2f57c0ae 100644 --- a/Controls/ScanWorkspace/Snapshot.cs +++ b/Controls/ScanWorkspace/Snapshot.cs @@ -21,9 +21,9 @@ public partial class ScanWorkspace { private enum SnapshotFormat { PngTransparent, Jpeg, Svg } - private void ExportSnapshotPngAlpha_Click(object sender, RoutedEventArgs e) => ExportSnapshot(SnapshotFormat.PngTransparent); - private void ExportSnapshotJpeg_Click(object sender, RoutedEventArgs e) => ExportSnapshot(SnapshotFormat.Jpeg); - private void ExportSnapshotSvg_Click(object sender, RoutedEventArgs e) => ExportSnapshot(SnapshotFormat.Svg); + private void ExportSnapshotPngAlpha_Click(object sender, RoutedEventArgs e) => Export("png"); + private void ExportSnapshotJpeg_Click(object sender, RoutedEventArgs e) => Export("jpg"); + private void ExportSnapshotSvg_Click(object sender, RoutedEventArgs e) => Export("svg"); private void ExportSnapshot(SnapshotFormat format) { diff --git a/Controls/ScanWorkspace/Topology.cs b/Controls/ScanWorkspace/Topology.cs index 102fde6b..c68f79d9 100644 --- a/Controls/ScanWorkspace/Topology.cs +++ b/Controls/ScanWorkspace/Topology.cs @@ -51,8 +51,11 @@ private void TopologyButton_Click(object sender, RoutedEventArgs e) /// that reached topology without an odd number of trips through that handler left the /// button hidden while the graph was on screen. /// - private void UpdateViewChrome() => + private void UpdateViewChrome() + { TopologyOrderButton.Visibility = _showTopology ? Visibility.Visible : Visibility.Collapsed; + UpdateExportButtonAvailability(); + } private void LoadTopologyOrder() { diff --git a/Shell/Workspace.cs b/Shell/Workspace.cs index 281903cb..58a5ac29 100644 --- a/Shell/Workspace.cs +++ b/Shell/Workspace.cs @@ -155,12 +155,6 @@ private ScanWorkspace NewScan(string target = "") export.FontSize = 15; export.Content = "\uE896"; export.SetResourceReference(StyleProperty, "RailButton"); - var exportTip = new TextBlock(); - var exportCaption = new System.Windows.Documents.Run(); - exportCaption.SetResourceReference(System.Windows.Documents.Run.TextProperty, "Str_TT_Export"); - exportTip.Inlines.Add(exportCaption); - exportTip.Inlines.Add(" (CSV Ctrl+E, HTML Ctrl+Shift+E, TXT Ctrl+Shift+X, PNG Ctrl+Shift+G, JPG Ctrl+Shift+J, map HTML Ctrl+Alt+H)"); - export.ToolTip = exportTip; export.SetResourceReference(System.Windows.Automation.AutomationProperties.NameProperty, "Str_TT_Export"); RailButtons.Children.Insert(0, export); ApplyToolbarAppearance(); diff --git a/tests/SpeedTest.Tests/ExportActionTests.cs b/tests/SpeedTest.Tests/ExportActionTests.cs new file mode 100644 index 00000000..2b4728d8 --- /dev/null +++ b/tests/SpeedTest.Tests/ExportActionTests.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using KillerScan.Controls; +using KillerScan.Engine; + +internal static class ExportActionTests +{ + internal static void Run() + { + string directory = Path.Combine(Path.GetTempPath(), "KillerScan-export-actions-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + using var workspace = new ScanWorkspace(); + workspace.LoadSnapshot("192.0.2.0/24", [new NetworkDevice { IpAddress = "192.0.2.1", Hostname = "device-a", OpenPorts = [80, 443] }, + new NetworkDevice { IpAddress = "192.0.2.2", Hostname = "device-b", OpenPorts = [22] }]); + workspace.SetView("services"); + ((TextBox)workspace.FindName("FilterInput")).Text = "device-a"; + var button = (Button)workspace.FindName("ExportButton"); + workspace.Export(); + button.ContextMenu.IsOpen = false; + var csvItems = button.ContextMenu.Items.OfType().Where(item => item.Visibility == Visibility.Visible && item.InputGestureText == "Ctrl+E").ToArray(); + Require(csvItems.Length == 1, "Services must offer exactly one CSV export action for Ctrl+E."); + Require(((MenuItem)workspace.FindName("ExportHtmlItem")).Visibility == Visibility.Collapsed, "Services must not advertise an unsupported HTML export."); + int dialogs = 0; + string path = Path.Combine(directory, "menu.csv"); + workspace.ExportDialog = dialog => { dialogs++; dialog.FileName = path; return true; }; + csvItems[0].RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + string menu = File.ReadAllText(path); + Require(menu.StartsWith("Service,Port,Name,IP Address,Type") && menu.Contains("device-a") && !menu.Contains("device-b"), "The menu exports the filtered Services dataset with one row per port."); + int rows = ((DataGrid)workspace.FindName("ServicesGrid")).Items.Count; + Require(menu.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries).Length == rows + 1, "Export row count matches the visible Services rows."); + path = Path.Combine(directory, "shortcut.csv"); + workspace.Export("csv"); + Require(File.ReadAllText(path) == menu, "Menu and keyboard context commands produce the same service CSV."); + workspace.Export("html"); + Require(dialogs == 2, "An unsupported service format does not open a save dialog."); + string tooltip = button.ToolTip is TextBlock text ? new TextRange(text.ContentStart, text.ContentEnd).Text : button.ToolTip?.ToString() ?? ""; + Require(tooltip.Contains("Ctrl+E") && !tooltip.Contains("HTML") && !tooltip.Contains("Ctrl+Shift+E"), "The Services tooltip lists only available shortcuts."); + workspace.SetView("devices"); + workspace.Export(); button.ContextMenu.IsOpen = false; + Require(Visible().Length == 2 && Visible().Any(item => item.Name == "ExportHtmlItem"), "Devices retains its CSV and HTML actions."); + path = Path.Combine(directory, "devices.csv"); + ((MenuItem)workspace.FindName("ExportCsvItem")).RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + Require(File.ReadAllText(path).StartsWith("IP Address,Hostname,MAC Address,Vendor,Type,Open Ports"), "The same CSV gesture uses the device schema in Devices."); + workspace.SetView("topology"); + workspace.Export(); button.ContextMenu.IsOpen = false; + Require(Visible().Select(item => item.Name).OrderBy(name => name).SequenceEqual(new[] { "ExportTopologyJpegItem", "ExportTopologyPngItem", "ExportTopologySvgItem" }), "Topology offers only its three supported snapshot formats."); + var signals = new List(); + workspace.ShellExportRequested += (_, format) => signals.Add(format); + workspace.ExportContext = "watch"; + workspace.Export(); button.ContextMenu.IsOpen = false; + foreach (var item in Visible()) item.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + Require(signals.SequenceEqual(new[] { "watch-csv", "watch-html", "watch-txt", "watch-png" }), "Watch menu actions share context dispatch without opening device writers."); + signals.Clear(); workspace.ExportContext = "terminal"; + workspace.Export(); button.ContextMenu.IsOpen = false; + Require(Visible().Length == 1, "Terminal exposes only its text export."); + Visible()[0].RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + Require(signals.SequenceEqual(new[] { "terminal-txt" }), "Terminal menu dispatch reaches its own writer."); + signals.Clear(); workspace.ExportContext = "history"; workspace.Export("csv"); + Require(signals.SequenceEqual(new[] { "history-csv" }), "History CSV retains the shell-owned export route."); + + MenuItem[] Visible() => button.ContextMenu.Items.OfType().Where(item => item.Visibility == Visibility.Visible).ToArray(); + } + finally { Directory.Delete(directory, true); } + Console.WriteLine("PASS: service export menu, tooltip and keyboard command share the filtered dataset."); + } + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/tests/SpeedTest.Tests/ScanLifecycleTests.cs b/tests/SpeedTest.Tests/ScanLifecycleTests.cs index ba75da96..b062506a 100644 --- a/tests/SpeedTest.Tests/ScanLifecycleTests.cs +++ b/tests/SpeedTest.Tests/ScanLifecycleTests.cs @@ -58,6 +58,7 @@ internal static void Run() "A first empty scan is recorded without creating a trusted-device baseline."); CheckDeepProfile(); ExportSnapshotTests.Run(); + ExportActionTests.Run(); WatchActionTests.Run(); Console.WriteLine("PASS: populated, empty, canceled and failed scan history through the workspace command."); } diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md index 294afe83..860cf472 100644 --- a/validation/adversarial-remediation-20261020.md +++ b/validation/adversarial-remediation-20261020.md @@ -58,6 +58,20 @@ Red: a started watch silently ignored IncludeTarget and created no requested car Initial green: the controlled run adds and diagnoses new targets, retains existing card identity and growing counters, deduplicates repeat requests and stops through the actual button command. The integrated scan lifecycle/export fixture also passes. Extended checks cover the 16-target limit and visible rejection without changing accepted targets. +## UI-006 diagnosis + +Ranked hypotheses before the export-action fixture: + +1. Services exposes both generic device CSV and service CSV with the same shortcut. +2. Menu handlers call writers directly while keyboard commands use context dispatch. +3. The shell's static all-format tooltip advertises unsupported exports for the current view. + +The fixture drives actual export menu-item events and the same context command called by keyboard shortcuts. Save dialogs are redirected to scratch files. It compares the visible service rows with exported rows and verifies unsupported formats do not open a dialog. + +Red: Services offered two visible Ctrl+E entries. Generic menu handlers bypassed context selection, while the command route already distinguished service CSV from device CSV. The repair routes menu and shortcut actions through the context command, shows just service CSV in Services and derives the rail tooltip from the active context. + +Green: the actual Services menu and keyboard command produce identical filtered service CSV with one row per visible port. Unsupported service HTML opens no dialog, and the tooltip lists only CSV. Devices still exports the device schema; Topology exposes its three snapshot actions; Watch, Terminal and History route to their own shell writers. The integrated scan lifecycle, export-provenance and watch-action fixtures pass. Rendered menu/tooltip inspection remains separate acceptance evidence. + ## Current evidence | Finding | Local commit | Focused evidence | From d2f2c175e89fc4dd4e7d5cf3d7c52e94ba3d4266 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:27:30 -0700 Subject: [PATCH 028/333] v1.8.4: Align upload documentation with verified sent-byte throughput --- CHANGELOG.md | 1 + Engine/README.md | 6 + Engine/SpeedTest/SpeedTestEngine.cs | 14 +- README.md | 2 + tests/KillerScan.Engine.Tests/Program.cs | 1 + .../UploadAccountingTests.cs | 134 ++++++++++++++++++ .../adversarial-remediation-20261020.md | 8 ++ 7 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 tests/KillerScan.Engine.Tests/UploadAccountingTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 061a8ccf..36ab625a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this ### Changed - Updated the app and engine JSON dependency to 10.0.13. +- Clarified upload throughput as sent payload, including in-flight requests, with a valid acknowledgment required. ### Fixed - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. diff --git a/Engine/README.md b/Engine/README.md index 1d1dd0e1..6a02c880 100644 --- a/Engine/README.md +++ b/Engine/README.md @@ -57,6 +57,12 @@ These are static and set once at startup. ARP and route lookups, plus the DNS cache flush, go through `INetworkPlatform`. `NetworkPlatform.Current` picks `WindowsNetworkPlatform` on Windows. On other systems it uses `UnsupportedNetworkPlatform`, so discovery relies on ping and TCP liveness and devices have no MAC address. Tests and other hosts can assign their own implementation. +## Speed-test byte counts + +`SpeedTestPhaseResult.BytesTransferred` counts payload read during download and payload written to the HTTP transport during upload. The upload rate includes in-flight requests and buffered payload at the measurement cutoff. At least one valid exact-size upload acknowledgment is required during measurement. Sent, receiver-observed and acknowledged byte totals can differ. + +Warmup bytes are separate from measured bytes. Neither direction's payload count includes HTTP headers or TLS framing. The result's `Elapsed` is the measured interval used to calculate `Mbps`. + ## Example and tests - `samples/KillerScan.Engine.Example` scans a target and prints JSON: `dotnet run --project samples/KillerScan.Engine.Example -- 192.168.1.0/24 --quick` diff --git a/Engine/SpeedTest/SpeedTestEngine.cs b/Engine/SpeedTest/SpeedTestEngine.cs index 25c36ab3..bbdd8495 100644 --- a/Engine/SpeedTest/SpeedTestEngine.cs +++ b/Engine/SpeedTest/SpeedTestEngine.cs @@ -15,8 +15,9 @@ namespace KillerScan.Engine.SpeedTest { /// A bounded HTTP throughput test against a compatible endpoint. Download counts - /// bytes read from the response stream; upload counts only bytes acknowledged by the server. - /// Neither number includes HTTP headers, TLS framing, or unfinished upload requests. + /// bytes read from the response stream. Upload counts payload written to the HTTP transport + /// during the timed phase, including in-flight requests, and requires a valid server acknowledgment. + /// These payload counts exclude HTTP headers and TLS framing. Written upload bytes may still be buffered. public sealed class SpeedTestEngine { private const int BufferSize = 64 * 1024; @@ -330,9 +331,8 @@ async Task LoadedLatencyAsync() token.ThrowIfCancellationRequested(); if (counters.Failure != null) throw Normalize(counters.Failure); var (finalBytes, finalScheduled, finalElapsed) = counters.Snapshot(); - // Upload bytes are counted as they leave the socket, so a stalled endpoint that - // never acknowledges any request would still register serialized bytes. Fail on - // the ack count instead of the sent count so a broken endpoint cannot fake a rate. + // Serialized payload can exist without a successful server exchange. Require an + // acknowledgment to establish endpoint validity while retaining sent-byte throughput. if (!warmup && (upload ? counters.Acknowledgments == 0 : finalBytes == 0)) throw new SpeedTestException(SpeedTestFailureKind.TransferFailed, upload ? "No upload payload was acknowledged before the measurement ended." @@ -555,8 +555,8 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon int size = Math.Min(_buffer.Length, _length - sent); await stream.WriteAsync(_buffer, 0, size, _token).ConfigureAwait(false); Interlocked.Add(ref _serialized, size); - // Count bytes as they leave the socket so in-flight uploads cut short by - // the phase-end cancellation are still reflected in the measured rate. + // Count successful HTTP transport writes before cutoff, including buffered + // payload in requests that are still in flight when the phase ends. _count(size); sent += size; } diff --git a/README.md b/README.md index a2cee929..c46fbf2f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ The website loads separate analytics from `koya.thekiller.net`; the no-usage-ana The default profile compares two, four, and eight concurrent HTTP transfers during nine seconds of warmup per direction, then runs a 15-second test. Upload keeps the connection count that improved measured throughput by at least 5%. Download always measures with all eight connections, because short warmup bursts do not predict sustained throughput. Request sizes adapt to the connection while staying within the deployed server limits. A 3 GiB budget per direction includes warmup and can shorten a very fast test; the terminal reports that limit. Final throughput uses payload bytes transferred during the timed phase. Upload bytes are counted as they are sent, but the test still requires a valid server acknowledgment. Server rate limits stop the test instead of adding retries to the timing window. +The upload count includes payload successfully written to the HTTP transport before cutoff, including requests still in flight. Some of those bytes can remain buffered before the receiver reads them. A valid acknowledgment establishes that the endpoint completed an upload exchange; later sent bytes remain part of the rate even if their request is unacknowledged. Received and acknowledged totals are separate quantities. + Latency is HTTP round-trip time to the selected endpoint, including server processing. Idle latency is the median of five samples after an unmeasured connection warmup. Jitter is the mean absolute difference between consecutive samples. Loaded latency is sampled on a separate connection during each measured phase. These measurements describe that route and server, and do not claim packet loss or universal ISP capacity. When you start a speed test, the app connects to `https://speed.killerscan.net/`, the dedicated [KillerScan endpoint](server/speedtest/README.md) hosted on Cloudflare Workers. Users do not need an account or server setup. Deployment credentials are kept outside the source and app. diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index cefecf43..f6a70582 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -16,6 +16,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--http-redirects" })) { await HttpRedirectTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--mac-cancellation" })) { await MacCancellationTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--snmp" })) { await SnmpTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--upload-accounting" })) { await UploadAccountingTests.Run(); return 0; } await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); await Run("CIDR expansion excludes the network and broadcast addresses", Subnet); diff --git a/tests/KillerScan.Engine.Tests/UploadAccountingTests.cs b/tests/KillerScan.Engine.Tests/UploadAccountingTests.cs new file mode 100644 index 00000000..e0569f5f --- /dev/null +++ b/tests/KillerScan.Engine.Tests/UploadAccountingTests.cs @@ -0,0 +1,134 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using KillerScan.Engine.SpeedTest; + +internal static class UploadAccountingTests +{ + internal static async Task Run() + { + foreach (bool partial in new[] { false, true }) + { + using var server = new Server(oneAck: true, partial: partial); + var result = await new SpeedTestEngine().RunAsync(Options(server), null, CancellationToken.None); + long received = server.Received, acknowledged = server.Acknowledged; + Require(acknowledged == 65536 && result.Upload.BytesTransferred > acknowledged, + "The selected sent-byte contract includes later unacknowledged payload after a valid acknowledgment."); + Require(partial ? received < result.Upload.BytesTransferred : received == result.Upload.BytesTransferred, + "Receiver-observed payload is recorded independently of serialized and acknowledged totals."); + Require(result.Upload.CompletedDuration && result.Upload.Elapsed >= TimeSpan.FromMilliseconds(500) && result.Upload.Elapsed < TimeSpan.FromSeconds(2), + "A stalled upload stops at the measurement interval."); + long serialized = result.Upload.BytesTransferred; + server.Drain(); + await Task.Delay(100); + Require(result.Upload.BytesTransferred == serialized, "Buffered tails after cutoff cannot change the frozen result."); + Console.WriteLine("UPLOAD partial=" + partial + " serialized=" + serialized + " receivedAtEnd=" + received + " acknowledged=" + acknowledged + " receivedAfterDrain=" + server.Received + " elapsedMs=" + result.Upload.Elapsed.TotalMilliseconds.ToString("F1")); + } + using (var server = new Server(oneAck: false, partial: false, noAck: true)) + { + try { await new SpeedTestEngine().RunAsync(Options(server), null, CancellationToken.None); throw new InvalidOperationException("No-ack upload must fail."); } + catch (SpeedTestException ex) { Require(ex.Kind == SpeedTestFailureKind.TransferFailed && server.Received > 0 && server.Acknowledged == 0, "Serialized and received bytes alone cannot establish endpoint validity."); } + } + using (var server = new Server(oneAck: false, partial: false)) + { + var result = await new SpeedTestEngine().RunAsync(Options(server), null, CancellationToken.None); + Require(result.Upload.ByteBudgetReached && result.Upload.BytesScheduled <= 262144 && result.Upload.BytesTransferred == server.Received && server.Received == server.Acknowledged, + "Normally completed uploads preserve byte budgets and agree with received/acknowledged payload."); + } + using (var server = new Server(oneAck: true, partial: true)) + using (var cancel = new CancellationTokenSource()) + { + var options = Options(server); options.PhaseDuration = TimeSpan.FromSeconds(5); + var progress = new Callback(value => { if (value.Phase == SpeedTestPhase.Upload && value.BytesTransferred > 0) cancel.Cancel(); }); + var clock = Stopwatch.StartNew(); + try { await new SpeedTestEngine().RunAsync(options, progress, cancel.Token); throw new InvalidOperationException("Canceled upload must not return a completed result."); } + catch (OperationCanceledException) { Require(clock.Elapsed < TimeSpan.FromSeconds(3), "User cancellation ends stalled payload work promptly."); } + } + Console.WriteLine("PASS: sent, received and acknowledged upload quantities retain their separate contracts."); + } + + private static SpeedTestOptions Options(Server server) => new() + { + Endpoint = server.Endpoint, WarmupDuration = TimeSpan.Zero, PhaseDuration = TimeSpan.FromMilliseconds(500), + RequestTimeout = TimeSpan.FromSeconds(2), MaximumStreams = 1, ByteBudgetPerPhase = 262144, + DownloadPayloadBytes = 65536, UploadPayloadBytes = 65536 + }; + private sealed class Callback(Action action) : IProgress { public void Report(SpeedTestProgress value) => action(value); } + private sealed class Server : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly ConcurrentBag _clients = new(); + private readonly TaskCompletionSource _drain = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly bool _oneAck, _partial, _noAck; + private long _received, _acknowledged; + private int _uploads; + internal long Received => Interlocked.Read(ref _received); + internal long Acknowledged => Interlocked.Read(ref _acknowledged); + internal Uri Endpoint { get; } + internal Server(bool oneAck, bool partial, bool noAck = false) + { + _oneAck = oneAck; _partial = partial; _noAck = noAck; + _listener.Start(); Endpoint = new Uri("http://127.0.0.1:" + ((IPEndPoint)_listener.LocalEndpoint).Port + "/"); _ = Accept(); + } + internal void Drain() => _drain.TrySetResult(true); + private async Task Accept() + { + while (!_stop.IsCancellationRequested) + try { var client = await _listener.AcceptTcpClientAsync(); _clients.Add(client); _ = Serve(client); } + catch when (_stop.IsCancellationRequested) { return; } + } + private async Task Serve(TcpClient client) + { + using (client) + try + { + var stream = client.GetStream(); + var buffer = new byte[4096]; + while (!_stop.IsCancellationRequested) + { + var header = new StringBuilder(); var next = new byte[1]; + while (header.Length < 16384 && !header.ToString().EndsWith("\r\n\r\n", StringComparison.Ordinal)) + { if (await stream.ReadAsync(next, 0, 1, _stop.Token) == 0) return; header.Append((char)next[0]); } + string[] lines = header.ToString().Split(new[] { "\r\n" }, StringSplitOptions.None); + bool upload = lines[0].StartsWith("POST ", StringComparison.Ordinal); + string path = lines[0].Split(' ')[1]; + int count = int.Parse(path.Split('?')[1].Split('&')[0].Split('=')[1]); + if (!upload) + { + await Respond(stream, count); + while (count > 0) { int size = Math.Min(count, buffer.Length); await stream.WriteAsync(buffer, 0, size, _stop.Token); count -= size; } + continue; + } + int sequence = Interlocked.Increment(ref _uploads); + int remaining = int.Parse(lines.Single(line => line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)).Split(':')[1]); + bool stall = _noAck || (_oneAck && sequence > 1); + bool paused = false; + while (remaining > 0) + { + int n = await stream.ReadAsync(buffer, 0, Math.Min(remaining, buffer.Length), _stop.Token); + if (n == 0) return; + remaining -= n; Interlocked.Add(ref _received, n); + if (stall && _partial && !paused) + { paused = true; await Task.WhenAny(_drain.Task, Task.Delay(Timeout.Infinite, _stop.Token)); _stop.Token.ThrowIfCancellationRequested(); } + } + if (stall) { await Task.Delay(Timeout.Infinite, _stop.Token); return; } + byte[] acknowledgment = Encoding.ASCII.GetBytes("{\"bytes\":" + count + "}"); + await Respond(stream, acknowledgment.Length); + await stream.WriteAsync(acknowledgment, 0, acknowledgment.Length, _stop.Token); + Interlocked.Add(ref _acknowledged, count); + } + } + catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException or OperationCanceledException) { } + } + private async Task Respond(NetworkStream stream, int length) + { + byte[] header = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: " + length + "\r\nConnection: keep-alive\r\n\r\n"); + await stream.WriteAsync(header, 0, header.Length, _stop.Token); + } + public void Dispose() { _stop.Cancel(); _drain.TrySetResult(true); _listener.Stop(); foreach (var client in _clients) client.Dispose(); } + } + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } +} diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md index 860cf472..c65c3733 100644 --- a/validation/adversarial-remediation-20261020.md +++ b/validation/adversarial-remediation-20261020.md @@ -72,6 +72,14 @@ Red: Services offered two visible Ctrl+E entries. Generic menu handlers bypassed Green: the actual Services menu and keyboard command produce identical filtered service CSV with one row per visible port. Unsupported service HTML opens no dialog, and the tooltip lists only CSV. Devices still exports the device schema; Topology exposes its three snapshot actions; Watch, Terminal and History route to their own shell writers. The integrated scan lifecycle, export-provenance and watch-action fixtures pass. Rendered menu/tooltip inspection remains separate acceptance evidence. +## ENG-001 investigation + +The approved contract retains sent-byte throughput with a valid acknowledgment required. The engine summary must describe that same contract. This is a documentation repair and controlled investigation, not an acknowledged-only algorithm change. + +The loopback fixture separately records the engine's serialized payload result, receiver-observed bytes and acknowledged bytes. One exact acknowledgment is followed by either a fully received unacknowledged request or a partially read request with a stalled receiver. It captures phase-end counts and drains buffered tails afterward. Separate cases retain no-ack rejection, normal budget behavior and user cancellation on both target frameworks. + +Both targets reported 131,072 serialized bytes and 65,536 acknowledged bytes. The fully received stalled-request case also recorded 131,072 received bytes. The partial-reader case recorded 69,632 bytes at phase completion and 131,072 after draining the buffered tail. Measured intervals were approximately 508 to 546 ms for a 500 ms phase. No-ack rejection, normal exact byte budgets and cancellation all passed on both targets. The result remained frozen after draining. This confirms the chosen sent-byte contract and its buffering limitation; no algorithm change was made. The class summary, app README and engine README now agree. + ## Current evidence | Finding | Local commit | Focused evidence | From ff760ae02ff00830f8efec5b3674075b9c8f3b62 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:33:40 -0700 Subject: [PATCH 029/333] v1.8.4: Stop release preparation when dependency auditing fails --- CHANGELOG.md | 1 + release.ps1 | 49 +++++++++++++-- tests/Release.Tests/DependencyAudit.Tests.ps1 | 62 +++++++++++++++++++ .../adversarial-remediation-20261020.md | 14 +++++ 4 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 tests/Release.Tests/DependencyAudit.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ab625a..6982239b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Clarified upload throughput as sent payload, including in-flight requests, with a valid acknowledgment required. ### Fixed +- Release dependency checks stop on restore or audit failure, malformed results, missing engine coverage and vulnerable packages. - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. - Demo device types and SSH usernames stay in memory without reading or changing saved real-device values. diff --git a/release.ps1 b/release.ps1 index 9908bc24..20812a78 100644 --- a/release.ps1 +++ b/release.ps1 @@ -238,12 +238,53 @@ Write-Host 'Punctuation OK' # --- 3. Vulnerable package scan (required at every release) --- Step "Scanning for vulnerable packages" -dotnet restore | Out-Null -$scan = dotnet list package --vulnerable --include-transitive 2>&1 | Out-String +$auditDirectory = Join-Path (Get-Location).Path 'obj/release-validation' +$null = New-Item -ItemType Directory -Path $auditDirectory -Force +$priorErrorPreference = $ErrorActionPreference +try { + $ErrorActionPreference = 'Continue' + $restore = dotnet restore KillerScan.sln 2>&1 | Out-String + $restoreExit = $LASTEXITCODE +} finally { $ErrorActionPreference = $priorErrorPreference } +[IO.File]::WriteAllText((Join-Path $auditDirectory 'restore.txt'), $restore) +if ($restoreExit -ne 0) { Fail "Dependency restore failed with exit $restoreExit. See $auditDirectory/restore.txt" } +try { + $ErrorActionPreference = 'Continue' + $scan = dotnet list KillerScan.sln package --vulnerable --include-transitive --format json 2>&1 | Out-String + $auditExit = $LASTEXITCODE +} finally { $ErrorActionPreference = $priorErrorPreference } +[IO.File]::WriteAllText((Join-Path $auditDirectory 'dependency-audit.json'), $scan) Write-Host $scan -if ($scan -match 'has the following vulnerable packages') { - Fail 'Vulnerable packages found. Resolve before releasing.' +if ($auditExit -ne 0) { Fail "Dependency audit failed with exit $auditExit. See $auditDirectory/dependency-audit.json" } +try { $audit = $scan | ConvertFrom-Json -ErrorAction Stop } +catch { Fail "Dependency audit returned malformed JSON. See $auditDirectory/dependency-audit.json" } +if ($audit.version -ne 1 -or -not $audit.projects -or -not $audit.sources -or $audit.problems) { + Fail 'Dependency audit is incomplete or reports an audit problem. Resolve before releasing.' +} +$auditedProjects = @($audit.projects | ForEach-Object { + if (-not $_.path) { Fail 'Dependency audit contains a project without a path.' } + $projectPath = if ([IO.Path]::IsPathRooted($_.path)) { $_.path } else { Join-Path (Get-Location).Path $_.path } + [IO.Path]::GetFullPath($projectPath) +}) +foreach ($required in @('KillerScan.csproj', 'Engine/KillerScan.Engine.csproj')) { + if ([IO.Path]::GetFullPath((Join-Path (Get-Location).Path $required)) -notin $auditedProjects) { + Fail "Dependency audit omitted $required. Resolve before releasing." + } +} +foreach ($project in $audit.projects) { + foreach ($framework in $project.frameworks) { + if (-not $framework.framework) { Fail 'Dependency audit contains an invalid target framework.' } + $packages = @($framework.topLevelPackages) + @($framework.transitivePackages) + foreach ($package in $packages) { + if ($null -eq $package) { continue } + if (-not $package.id -or -not $package.PSObject.Properties['vulnerabilities']) { + Fail 'Dependency audit contains incomplete package data.' + } + if ($package.vulnerabilities) { Fail "Vulnerable package $($package.id) found. Resolve before releasing." } + } + } } +Write-Host 'Dependency audit completed with no known vulnerable packages.' Step "Testing the native speed-test engine and endpoint" if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Fail 'Node.js 22 or newer is required to test the speed-test endpoint' } diff --git a/tests/Release.Tests/DependencyAudit.Tests.ps1 b/tests/Release.Tests/DependencyAudit.Tests.ps1 new file mode 100644 index 00000000..aab7dae1 --- /dev/null +++ b/tests/Release.Tests/DependencyAudit.Tests.ps1 @@ -0,0 +1,62 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$ScratchRoot) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$source = Get-Content -LiteralPath (Join-Path $repo 'release.ps1') -Raw +$start = $source.IndexOf('Step "Scanning for vulnerable packages"') +$end = $source.IndexOf('Step "Testing the native speed-test engine and endpoint"', $start) +if ($start -lt 0 -or $end -le $start) { throw 'The dependency gate boundaries are missing.' } +$gate = [scriptblock]::Create($source.Substring($start, $end - $start)) +$folder = Join-Path ([IO.Path]::GetFullPath($ScratchRoot)) ('dependency-audit-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $folder +$nativeFailure = Join-Path $folder 'native-failure.cmd' +[IO.File]::WriteAllText($nativeFailure, "@echo off`r`necho fixture native stderr 1>&2`r`nexit /b 44`r`n") +$clean = '{"version":1,"sources":["https://api.nuget.org/v3/index.json"],"projects":[{"path":"KillerScan.csproj"},{"path":"Engine/KillerScan.Engine.csproj"}]}' +$vulnerable = '{"version":1,"sources":["https://api.nuget.org/v3/index.json"],"projects":[{"path":"KillerScan.csproj","frameworks":[{"framework":"net48","topLevelPackages":[{"id":"Fixture.Package","vulnerabilities":[{"severity":"High","advisoryurl":"https://example.invalid/advisory"}]}]}]},{"path":"Engine/KillerScan.Engine.csproj"}]}' +$cases = @( + @{ Name = 'RestoreFailure'; Restore = 42; Audit = 0; Json = $clean; Reject = $true; Reason = 'restore failed with exit 42' }, + @{ Name = 'NativeRestoreStderr'; Restore = 44; Audit = 0; Json = $clean; Reject = $true; Reason = 'restore failed with exit 44'; Native = $true }, + @{ Name = 'AuditFailure'; Restore = 0; Audit = 43; Json = 'audit unavailable'; Reject = $true; Reason = 'audit failed with exit 43' }, + @{ Name = 'NativeAuditStderr'; Restore = 0; Audit = 44; Json = $clean; Reject = $true; Reason = 'audit failed with exit 44'; NativeAudit = $true }, + @{ Name = 'Malformed'; Restore = 0; Audit = 0; Json = '{'; Reject = $true; Reason = 'malformed JSON' }, + @{ Name = 'MissingProjects'; Restore = 0; Audit = 0; Json = '{"version":1,"projects":[]}'; Reject = $true; Reason = 'incomplete' }, + @{ Name = 'MissingEngine'; Restore = 0; Audit = 0; Json = '{"version":1,"sources":["fixture"],"projects":[{"path":"KillerScan.csproj"}]}'; Reject = $true; Reason = 'omitted Engine' }, + @{ Name = 'AuditProblem'; Restore = 0; Audit = 0; Json = $clean.Replace('"sources":', '"problems":[{"text":"feed unavailable"}],"sources":'); Reject = $true; Reason = 'audit problem' }, + @{ Name = 'Vulnerable'; Restore = 0; Audit = 0; Json = $vulnerable; Reject = $true; Reason = 'Vulnerable package Fixture.Package' }, + @{ Name = 'TransitiveVulnerable'; Restore = 0; Audit = 0; Json = $vulnerable.Replace('topLevelPackages', 'transitivePackages'); Reject = $true; Reason = 'Vulnerable package Fixture.Package' }, + @{ Name = 'UnsupportedSchema'; Restore = 0; Audit = 0; Json = $clean.Replace('"version":1', '"version":2'); Reject = $true; Reason = 'incomplete' }, + @{ Name = 'IncompletePackage'; Restore = 0; Audit = 0; Json = $clean.Replace('"path":"KillerScan.csproj"', '"path":"KillerScan.csproj","frameworks":[{"framework":"net48","topLevelPackages":[{"id":"Incomplete"}]}]'); Reject = $true; Reason = 'incomplete package data' }, + @{ Name = 'Clean'; Restore = 0; Audit = 0; Json = $clean; Reject = $false } +) +foreach ($case in $cases) { + $caseFolder = Join-Path $folder $case.Name + $null = New-Item -ItemType Directory -Path $caseFolder + Push-Location $caseFolder + try { + $calls = [Collections.Generic.List[string]]::new() + function Step { } + function Fail([string]$message) { throw $message } + function dotnet { + $calls.Add(($args -join ' ')) + if ($case.Native -or ($case.NativeAudit -and $args[0] -ne 'restore')) { & $nativeFailure; return } + if ($args[0] -eq 'restore') { $global:LASTEXITCODE = $case.Restore; return 'fixture restore diagnostic' } + $global:LASTEXITCODE = $case.Audit + return $case.Json + } + $rejected = $false + $reason = '' + try { & $gate | Out-Null } catch { $rejected = $true; $reason = $_.Exception.Message } + if ($rejected -ne $case.Reject) { throw ($case.Name + ': dependency gate returned the wrong verdict.') } + if ($ErrorActionPreference -ne 'Stop') { throw 'The gate did not restore the caller error preference.' } + if ($case.Reject -and $reason -notmatch $case.Reason) { throw ($case.Name + ': wrong rejection reason: ' + $reason) } + if ($case.Restore -ne 0 -and $calls.Count -ne 1) { throw 'Restore failure reached the audit command.' } + if ($case.Restore -eq 0 -and $calls.Count -ne 2) { throw 'The gate did not execute restore and audit exactly once.' } + if ($source -match '--format json' -and -not (Test-Path -LiteralPath 'obj/release-validation/restore.txt')) { throw 'Restore diagnostics were not retained.' } + if ($case.Restore -eq 0 -and $source -match '--format json' -and -not (Test-Path -LiteralPath 'obj/release-validation/dependency-audit.json')) { throw 'Audit output was not retained.' } + if ($case.Native -and (Get-Content -LiteralPath 'obj/release-validation/restore.txt' -Raw) -notmatch 'fixture native stderr') { throw 'Native restore stderr was not retained.' } + if ($case.NativeAudit -and (Get-Content -LiteralPath 'obj/release-validation/dependency-audit.json' -Raw) -notmatch 'fixture native stderr') { throw 'Native audit stderr was not retained.' } + Write-Output ('PASS: ' + $case.Name) + } finally { Pop-Location } +} +Write-Output ('Evidence: ' + $folder) diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md index c65c3733..d8f31fc4 100644 --- a/validation/adversarial-remediation-20261020.md +++ b/validation/adversarial-remediation-20261020.md @@ -80,6 +80,20 @@ The loopback fixture separately records the engine's serialized payload result, Both targets reported 131,072 serialized bytes and 65,536 acknowledged bytes. The fully received stalled-request case also recorded 131,072 received bytes. The partial-reader case recorded 69,632 bytes at phase completion and 131,072 after draining the buffered tail. Measured intervals were approximately 508 to 546 ms for a 500 ms phase. No-ack rejection, normal exact byte budgets and cancellation all passed on both targets. The result remained frozen after draining. This confirms the chosen sent-byte contract and its buffering limitation; no algorithm change was made. The class summary, app README and engine README now agree. +## FAM-001 diagnosis + +Ranked hypotheses before the extracted release-gate fixture: + +1. Native restore/audit failures are overwritten by later commands because their exits are unchecked. +2. English text matching accepts a failed or malformed audit as clean. +3. A structured result can omit an intended project or report a feed problem without findings. + +The fixture executes only the actual dependency-gate source block with the dotnet process boundary replaced. Restore/audit failures, malformed/missing project data, audit problems, vulnerabilities and clean output are tested in unique scratch directories. It cannot reach build, signing or publication. + +Red: the actual release gate continued after simulated restore exit 42. The repair captures each native exit immediately, saves raw diagnostics, requires structured audit schema and app/engine coverage, and rejects audit problems or vulnerable packages before later release steps. + +The extracted-gate cases pass under Windows PowerShell 5.1 and PowerShell 7 with rejection reasons verified, including transitive vulnerabilities, unsupported schema and incomplete package data. Actual native stderr/exit fixtures verify diagnostic preservation and error-preference restoration for both commands. The real extracted gate also completed a fresh solution restore and structured audit covering the app, engine, engine tests and sample, with no known vulnerabilities reported. Diagnostics are retained in obj/release-validation. No later release steps ran. The existing five unsigned/dry-run publication-guard fixtures also pass without reaching real publication. + ## Current evidence | Finding | Local commit | Focused evidence | From 137dd1ba8b0f50ea61b5d80de65e18565b292c34 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:37:01 -0700 Subject: [PATCH 030/333] v1.8.4: Monitor the engine NuGet release channel --- .github/workflows/release-sync.yml | 5 +- CHANGELOG.md | 1 + tests/Release.Tests/ReleaseSync.Tests.ps1 | 69 +++++++++++++++++++ .../adversarial-remediation-20261020.md | 14 ++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/Release.Tests/ReleaseSync.Tests.ps1 diff --git a/.github/workflows/release-sync.yml b/.github/workflows/release-sync.yml index 98971f65..5e16fe7f 100644 --- a/.github/workflows/release-sync.yml +++ b/.github/workflows/release-sync.yml @@ -20,12 +20,13 @@ jobs: env: APP_NAME: KillerScan PROJECT_PATH: KillerScan.csproj + # The engine reads Version from the app project. Compare its published package to the release. ENGINE_PROJECT_PATH: EXPECTED_ASSETS: KillerScan.exe,SHA256SUMS.txt CHOCOLATEY_ID: killerscan WINGET_ID: SteveTheKiller.KillerScan WINGET_MANIFEST_ROOT: manifests/s/SteveTheKiller/KillerScan - NUGET_ID: + NUGET_ID: killerscan.engine REQUESTED_VERSION: ${{ inputs.version }} GH_TOKEN: ${{ github.token }} steps: @@ -72,7 +73,7 @@ jobs: } function Get-PublishFallback([string]$workflowName, [timespan]$age) { $json = gh run list --workflow $workflowName --branch $tag --limit 1 --json status,conclusion,url - $runs = @($json | ConvertFrom-Json) + $runs = @($json | ConvertFrom-Json | Where-Object { $null -ne $_ -and $_.status }) if ($runs.Count -and $runs[0].conclusion -eq 'failure') { return @('Failed', $runs[0].url) } if ($runs.Count -and $runs[0].status -ne 'completed') { return @('Pending', $runs[0].url) } if ($age.TotalDays -ge 7) { return @('Missing', 'Not published after seven days') } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6982239b..38a45a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Clarified upload throughput as sent payload, including in-flight requests, with a valid acknowledgment required. ### Fixed +- Release monitoring includes KillerScan.Engine on NuGet and reports missing or failed publication. - Release dependency checks stop on restore or audit failure, malformed results, missing engine coverage and vulnerable packages. - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. - Concurrent app instances preserve saved scans and retention changes instead of overwriting newer history. diff --git a/tests/Release.Tests/ReleaseSync.Tests.ps1 b/tests/Release.Tests/ReleaseSync.Tests.ps1 new file mode 100644 index 00000000..51f7af50 --- /dev/null +++ b/tests/Release.Tests/ReleaseSync.Tests.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$ScratchRoot) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$workflow = Get-Content -LiteralPath (Join-Path $repo '.github/workflows/release-sync.yml') -Raw +$run = $workflow.IndexOf(' run: |') +if ($run -lt 0) { throw 'The monitor inline script is missing.' } +$body = (($workflow.Substring($run) -split '\r?\n' | Select-Object -Skip 1 | Where-Object { $_ -match '^ ' }) -replace '^ ', '') -join "`n" +$tokens = $null; $errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($body, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +$http = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Get-HttpStatus' }, $true))[0] +if (-not $http) { throw 'The monitor HTTP boundary is missing.' } +$stub = 'function Get-HttpStatus([string]$url) { if ($url -match "api.nuget.org") { return $case.Http }; return 200 }' +$body = $body.Remove($http.Extent.StartOffset, $http.Extent.EndOffset - $http.Extent.StartOffset).Insert($http.Extent.StartOffset, $stub) +$monitor = [scriptblock]::Create($body) +$folder = Join-Path ([IO.Path]::GetFullPath($ScratchRoot)) ('release-sync-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $folder +$appProjectSource = Get-Content -LiteralPath (Join-Path $repo 'KillerScan.csproj') -Raw +$fixtureVersion = ([xml]$appProjectSource).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 +$engineProjectSource = Get-Content -LiteralPath (Join-Path $repo 'Engine/KillerScan.Engine.csproj') -Raw +$names = @('APP_NAME', 'PROJECT_PATH', 'ENGINE_PROJECT_PATH', 'EXPECTED_ASSETS', 'CHOCOLATEY_ID', 'WINGET_ID', 'WINGET_MANIFEST_ROOT', 'NUGET_ID', 'GITHUB_REPOSITORY', 'REQUESTED_VERSION', 'GITHUB_STEP_SUMMARY') +$original = @{} +try { + foreach ($name in $names) { + $original[$name] = [Environment]::GetEnvironmentVariable($name) + $match = [regex]::Match($workflow, '(?m)^ ' + $name + ':[ \t]*([^\r\n]*)') + [Environment]::SetEnvironmentVariable($name, $match.Groups[1].Value.Trim()) + } + $env:GITHUB_REPOSITORY = 'fixture/KillerScan'; $env:REQUESTED_VERSION = '' + foreach ($case in @( + @{ Name = 'Published'; Http = 200; Age = 8; Run = 'none'; Expected = 'Published'; Blocks = $false }, + @{ Name = 'MissingOld'; Http = 404; Age = 8; Run = 'none'; Expected = 'Missing'; Blocks = $true }, + @{ Name = 'ReviewWindow'; Http = 404; Age = 1; Run = 'none'; Expected = 'Pending'; Blocks = $false }, + @{ Name = 'FailedPublishing'; Http = 404; Age = 1; Run = 'failure'; Expected = 'Failed'; Blocks = $true }, + @{ Name = 'Publishing'; Http = 404; Age = 1; Run = 'running'; Expected = 'Pending'; Blocks = $false }, + @{ Name = 'CompletedButMissing'; Http = 404; Age = 8; Run = 'success'; Expected = 'Missing'; Blocks = $true } + )) { + $env:GITHUB_STEP_SUMMARY = Join-Path $folder ($case.Name + '.md') + function git { + $global:LASTEXITCODE = 0 + if ($args[0] -eq 'cat-file') { return } + if ($args[0] -eq 'show' -and $args[1] -like '*:KillerScan.csproj') { return $appProjectSource } + if ($args[0] -eq 'show' -and $args[1] -like '*:Engine/KillerScan.Engine.csproj') { return $engineProjectSource } + throw ('Unexpected git command: ' + ($args -join ' ')) + } + function gh { + $global:LASTEXITCODE = 0 + if ($args[0] -eq 'api') { + return (@{ tag_name = 'v' + $fixtureVersion; published_at = [DateTimeOffset]::UtcNow.AddDays(-$case.Age).ToString('o'); assets = @(@{name='KillerScan.exe'}, @{name='SHA256SUMS.txt'}) } | ConvertTo-Json -Depth 5 -Compress) + } + if ($args[0] -eq 'run' -and $args[1] -eq 'list') { + if ($case.Run -eq 'none') { return '[]' } + return (@(@{ status = $(if ($case.Run -eq 'running') { 'in_progress' } else { 'completed' }); conclusion = $case.Run; url = 'https://example.invalid/fixture-run' }) | ConvertTo-Json -Compress) + } + throw ('Unexpected gh command: ' + ($args -join ' ')) + } + $blocked = $false; $reason = '' + try { . $monitor | Out-Null } catch { $blocked = $true; $reason = $_.Exception.Message + ' ' + $_.ScriptStackTrace } + if (-not (Test-Path -LiteralPath $env:GITHUB_STEP_SUMMARY)) { throw ($case.Name + ': summary was not generated: ' + $reason) } + $summary = Get-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Raw + if ($summary -notmatch ('\| NuGet \| ' + $case.Expected + ' \|')) { throw ($case.Name + ': required NuGet row is missing or incorrect.') } + if ($blocked -ne $case.Blocks) { throw ($case.Name + ': incorrect blocking outcome: ' + $reason) } + if ($summary -match '\| Engine source \| Mismatch') { throw 'The monitor compared the unevaluated engine version expression.' } + Write-Output ('PASS: ' + $case.Name) + } +} finally { foreach ($name in $names) { [Environment]::SetEnvironmentVariable($name, $original[$name]) } } +Write-Output ('Evidence: ' + $folder) diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md index d8f31fc4..98e64905 100644 --- a/validation/adversarial-remediation-20261020.md +++ b/validation/adversarial-remediation-20261020.md @@ -94,6 +94,20 @@ Red: the actual release gate continued after simulated restore exit 42. The repa The extracted-gate cases pass under Windows PowerShell 5.1 and PowerShell 7 with rejection reasons verified, including transitive vulnerabilities, unsupported schema and incomplete package data. Actual native stderr/exit fixtures verify diagnostic preservation and error-preference restoration for both commands. The real extracted gate also completed a fresh solution restore and structured audit covering the app, engine, engine tests and sample, with no known vulnerabilities reported. Diagnostics are retained in obj/release-validation. No later release steps ran. The existing five unsigned/dry-run publication-guard fixtures also pass without reaching real publication. +## FAM-002 diagnosis + +Ranked hypotheses before the monitor fixture: + +1. Empty NUGET_ID disables the entire package row. +2. Missing publication receives the wrong review-window or failed-run outcome. +3. A literal engine-project version comparison would misread its MSBuild expression. + +The fixture executes the workflow's actual inline monitor with only HTTP, Git and GitHub process boundaries replaced. It preserves the configured environment, uses app/engine project source, and captures the generated channel table under scratch. No external services are contacted. + +Red: the published-package fixture generated a table without any NuGet row. Setting NUGET_ID to killerscan.engine enables the existing version-specific package lookup. ENGINE_PROJECT_PATH stays intentionally empty because the engine derives its version from the app project rather than a literal XML version. The package is compared with the requested release version. + +The six monitor scenarios pass under PowerShell 7. Cross-version fixture execution also exposed PowerShell 5.1's different empty-JSON-array pipeline behavior; the fallback now keeps only entries with a run status before deciding pending versus missing. A real MSBuild property evaluation confirms the engine version resolves to 1.8.4, matching the current app version. + ## Current evidence | Finding | Local commit | Focused evidence | From 53a68f2c4ac1c5c4a9de7635c9b0397f70d6006b Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:42:58 -0700 Subject: [PATCH 031/333] v1.8.4: Refresh source bundles and fail Publish on bundle errors --- CHANGELOG.md | 1 + KillerScan.csproj | 2 +- build/bundle-source.ps1 | 87 +++++++++------ tests/Release.Tests/SourceBundle.Tests.ps1 | 101 ++++++++++++++++++ .../adversarial-remediation-20261020.md | 14 +++ 5 files changed, 173 insertions(+), 32 deletions(-) create mode 100644 tests/Release.Tests/SourceBundle.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a45a27..bd769bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Clarified upload throughput as sent payload, including in-flight requests, with a valid acknowledgment required. ### Fixed +- Repeated same-version Publish refreshes the source ZIP and fails clearly if the archive cannot be replaced. - Release monitoring includes KillerScan.Engine on NuGet and reports missing or failed publication. - Release dependency checks stop on restore or audit failure, malformed results, missing engine coverage and vulnerable packages. - Preference and history saves preserve existing files on failure, keep unsaved edits for retry, and report when changes could not be saved. diff --git a/KillerScan.csproj b/KillerScan.csproj index 17d2b2be..a988921c 100644 --- a/KillerScan.csproj +++ b/KillerScan.csproj @@ -98,7 +98,7 @@ - + diff --git a/build/bundle-source.ps1 b/build/bundle-source.ps1 index 0f7614d0..89b75671 100644 --- a/build/bundle-source.ps1 +++ b/build/bundle-source.ps1 @@ -9,53 +9,78 @@ param( ) $ErrorActionPreference = 'Stop' +if ($AppName -match '[\\/:*?"<>|]' -or $Version -match '[\\/:*?"<>|]') { throw 'Bundle name and version must be filename components.' } -$projectDirFull = (Resolve-Path $ProjectDir).Path +$projectDirFull = (Resolve-Path -LiteralPath $ProjectDir).Path $publishDirFull = if ([System.IO.Path]::IsPathRooted($PublishDir)) { $PublishDir } else { Join-Path $projectDirFull $PublishDir } +$publishDirFull = [IO.Path]::GetFullPath($publishDirFull) if (-not (Test-Path $publishDirFull)) { New-Item -ItemType Directory -Force -Path $publishDirFull | Out-Null } $zip = Join-Path $publishDirFull "$AppName-$Version-src.zip" -# Never overwrite or delete an existing source bundle - keep it as-is. -if (Test-Path $zip) { - Write-Host "Source bundle already present, keeping it: $zip" -ForegroundColor DarkGray - return -} - -$staging = Join-Path $env:TEMP "$AppName-src-$([guid]::NewGuid())" +$identity = [guid]::NewGuid().ToString('N') +$temporaryZip = Join-Path $publishDirFull "$AppName-$Version-src.$identity.tmp.zip" +$backup = $temporaryZip + '.original' +$temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$staging = [IO.Path]::GetFullPath((Join-Path $temporaryRoot "$AppName-src-$identity")) try { New-Item -ItemType Directory -Force -Path $staging | Out-Null - Push-Location $projectDirFull + $git = [Diagnostics.Process]::new() + $git.StartInfo.FileName = 'git' + $git.StartInfo.Arguments = '-c core.quotePath=false ls-files -z' + $git.StartInfo.WorkingDirectory = $projectDirFull + $git.StartInfo.UseShellExecute = $false + $git.StartInfo.CreateNoWindow = $true + $git.StartInfo.RedirectStandardOutput = $true + $git.StartInfo.RedirectStandardError = $true + $git.StartInfo.StandardOutputEncoding = [Text.Encoding]::UTF8 + $git.StartInfo.StandardErrorEncoding = [Text.Encoding]::UTF8 try { - # Exclude the landing site: it is a separate deployable, not app source, and a - # release's own exe hash can never live correctly inside the source it is built - # from (circular). Keeps the bundle buildable-app-only and free of stale site info. - $files = @(& git ls-files 2>$null | Where-Object { $_ -notlike 'scan-landing/*' }) - if ($LASTEXITCODE -ne 0 -or $files.Count -eq 0) { - Write-Warning "Source bundle skipped: git ls-files returned no tracked files (is git installed and is this a repo?)." - return - } - foreach ($f in $files) { - # git ls-files still lists files that were deleted but not yet committed; skip those. - if (-not (Test-Path $f)) { continue } - $dst = Join-Path $staging $f - $parent = Split-Path $dst -Parent - if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } - Copy-Item $f $dst -Force - } - $rootLicense = Join-Path $projectDirFull 'LICENSE' - if (Test-Path $rootLicense) { Copy-Item $rootLicense (Join-Path $staging 'LICENSE') -Force } - } finally { - Pop-Location + $null = $git.Start() + $output = $git.StandardOutput.ReadToEndAsync() + $errorOutput = $git.StandardError.ReadToEndAsync() + $git.WaitForExit() + $gitExit = $git.ExitCode + $fileList = $output.GetAwaiter().GetResult() + $diagnostic = $errorOutput.GetAwaiter().GetResult() + if ($gitExit -ne 0) { throw "Source discovery failed with exit ${gitExit}: $diagnostic" } + } finally { $git.Dispose() } + # The landing site is a separate deliverable and carries the release's executable hash. + $files = @($fileList.Split([char]0) | Where-Object { $_.Length -gt 0 -and $_ -notlike 'scan-landing/*' }) + if ($files.Count -eq 0) { throw 'Source discovery returned no tracked files.' } + foreach ($f in $files) { + $source = Join-Path $projectDirFull $f + # Tracked files deleted in the working tree are not inputs to this publish. + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { continue } + $dst = Join-Path $staging $f + $parent = Split-Path $dst -Parent + if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } + Copy-Item -LiteralPath $source -Destination $dst -Force } - Compress-Archive -Path (Join-Path $staging '*') -DestinationPath $zip -Force + $rootLicense = Join-Path $projectDirFull 'LICENSE' + if (Test-Path -LiteralPath $rootLicense) { Copy-Item -LiteralPath $rootLicense -Destination (Join-Path $staging 'LICENSE') -Force } + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::CreateFromDirectory($staging, $temporaryZip) + $written = [IO.File]::Open($temporaryZip, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + try { $written.Flush($true) } finally { $written.Dispose() } + if ([IO.File]::Exists($zip)) { + try { [IO.File]::Replace($temporaryZip, $zip, $backup) } + catch { + if (-not [IO.File]::Exists($zip) -and [IO.File]::Exists($backup)) { [IO.File]::Move($backup, $zip) } + throw + } + if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) } + } else { [IO.File]::Move($temporaryZip, $zip) } Write-Host "Source bundle: $zip" -ForegroundColor Green } finally { - Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue + if ([IO.File]::Exists($temporaryZip)) { [IO.File]::Delete($temporaryZip) } + $allowedRoot = $temporaryRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (-not $staging.StartsWith($allowedRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Source staging cleanup escaped the temporary root.' } + if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction Continue } } diff --git a/tests/Release.Tests/SourceBundle.Tests.ps1 b/tests/Release.Tests/SourceBundle.Tests.ps1 new file mode 100644 index 00000000..3f3ce1d2 --- /dev/null +++ b/tests/Release.Tests/SourceBundle.Tests.ps1 @@ -0,0 +1,101 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$ScratchRoot) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$root = Join-Path ([IO.Path]::GetFullPath($ScratchRoot)) ('source-bundle-' + [guid]::NewGuid().ToString('N') + ' ' + [char]0x00e9) +$null = New-Item -ItemType Directory -Path (Join-Path $root 'build') -Force +[xml]$appProject = Get-Content -LiteralPath (Join-Path $repo 'KillerScan.csproj') -Raw +$target = $appProject.SelectSingleNode('/Project/Target[@Name="BundleSource"]').OuterXml +if (-not $target) { throw 'The actual source-bundle Publish target is missing.' } +$template = @' + + + $(MSBuildThisFileDirectory) + 9.9.9BundleFixture + $(MSBuildThisFileDirectory)published\ + + + + + + __BUNDLE_TARGET__ + +'@ +[IO.File]::WriteAllText((Join-Path $root 'Fixture.proj'), $template.Replace('__BUNDLE_TARGET__', $target)) +Copy-Item -LiteralPath (Join-Path $repo 'build/bundle-source.ps1') -Destination (Join-Path $root 'build/bundle-source.ps1') +[IO.File]::WriteAllText((Join-Path $root '.gitignore'), "published/`r`nobj/`r`n") +$source = Join-Path $root 'source.txt' +[IO.File]::WriteAllText($source, 'first source') +[IO.File]::WriteAllText((Join-Path $root 'deleted.txt'), 'deleted input') +$null = New-Item -ItemType Directory -Path (Join-Path $root 'scan-landing') +[IO.File]::WriteAllText((Join-Path $root 'scan-landing/index.html'), 'separate landing site') +$unicodeName = 'caf' + [char]0x00e9 + ' source.txt' +Push-Location $root +try { + git init --quiet + if ($LASTEXITCODE -ne 0) { throw 'Fixture repository initialization failed.' } + git add -- . + if ($LASTEXITCODE -ne 0) { throw 'Fixture tracking failed.' } + Remove-Item -LiteralPath (Join-Path $root 'deleted.txt') + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = Join-Path $root 'published/BundleFixture-9.9.9-src.zip' + function Publish-Fixture { + $output = dotnet msbuild Fixture.proj -t:Publish -nologo 2>&1 | Out-String + $exit = $LASTEXITCODE + return @{ Exit = $exit; Output = $output } + } + function Read-Source { + $archive = [IO.Compression.ZipFile]::OpenRead($zip) + try { + $entry = $archive.GetEntry('source.txt') + if (-not $entry) { throw 'The bundle omitted its source input.' } + $reader = [IO.StreamReader]::new($entry.Open()) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } + } finally { $archive.Dispose() } + } + $first = Publish-Fixture + if ($first.Exit -ne 0 -or -not (Test-Path -LiteralPath $zip)) { throw ('First publish failed: ' + $first.Output) } + if ((Read-Source) -ne 'first source') { throw 'First publish bundled the wrong source.' } + if ([IO.File]::ReadAllText($source) -ne 'first source') { throw 'Fixture source changed unexpectedly.' } + [IO.File]::WriteAllText($source, 'second source') + $second = Publish-Fixture + if ($second.Exit -ne 0 -or (Read-Source) -ne 'second source' -or [IO.File]::ReadAllText((Join-Path $root 'published/app.bin')) -ne 'second source') { + throw ('Same-version Publish must pair the new build with current tracked source. ' + $second.Output) + } + [IO.File]::WriteAllText((Join-Path $root $unicodeName), 'Unicode source') + git add -- $unicodeName + if ($LASTEXITCODE -ne 0) { throw 'Unicode fixture tracking failed.' } + $unicode = Publish-Fixture + if ($unicode.Exit -ne 0) { throw ('Unicode source publish failed: ' + $unicode.Output) } + & (Join-Path $root 'build/bundle-source.ps1') -ProjectDir $root -Version 9.9.9 -AppName BundleFixture -PublishDir published | Out-Null + $archive = [IO.Compression.ZipFile]::OpenRead($zip) + try { + if (-not $archive.GetEntry($unicodeName)) { throw 'The source bundle omitted a tracked Unicode filename.' } + if ($archive.GetEntry('deleted.txt') -or $archive.GetEntry('scan-landing/index.html')) { throw 'Deleted inputs or the separate landing site entered the source bundle.' } + } + finally { $archive.Dispose() } + $before = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash + $leftover = Join-Path $root 'published/unrelated.tmp.zip' + [IO.File]::WriteAllText($leftover, 'preserve neighboring artifact') + $locked = [IO.File]::Open($zip, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + if ([IO.File]::ReadAllText($source) -ne 'second source') { throw 'Fixture source changed unexpectedly.' } + [IO.File]::WriteAllText($source, 'third source') + $denied = Publish-Fixture + if ($denied.Exit -eq 0) { throw 'Publish must fail when the matching source archive cannot be replaced.' } + if ((Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash -ne $before) { throw 'Denied replacement changed the prior complete source bundle.' } + } finally { $locked.Dispose() } + $retry = Publish-Fixture + if ($retry.Exit -ne 0 -or (Read-Source) -ne 'third source') { throw ('Source-bundle replacement could not be retried: ' + $retry.Output) } + if ([IO.File]::ReadAllText($leftover) -ne 'preserve neighboring artifact') { throw 'The bundler deleted an unrelated output artifact.' } + $untracked = Join-Path $root 'no-repository' + $null = New-Item -ItemType Directory -Path $untracked + $discoveryRejected = $false + try { & (Join-Path $root 'build/bundle-source.ps1') -ProjectDir $untracked -Version 9.9.9 -AppName BundleFixture -PublishDir published 2>&1 | Out-Null } + catch { $discoveryRejected = $true } + if (-not $discoveryRejected) { throw 'Expected source discovery to reject a directory with no tracked source.' } + Write-Output 'PASS: source discovery failure is explicit.' +} finally { Pop-Location } +Write-Output 'PASS: repeated same-version Publish refreshes source, rejects locked output and preserves prior artifacts.' +Write-Output ('Evidence: ' + $root) diff --git a/validation/adversarial-remediation-20261020.md b/validation/adversarial-remediation-20261020.md index 98e64905..e05e55bb 100644 --- a/validation/adversarial-remediation-20261020.md +++ b/validation/adversarial-remediation-20261020.md @@ -108,6 +108,20 @@ Red: the published-package fixture generated a table without any NuGet row. Sett The six monitor scenarios pass under PowerShell 7. Cross-version fixture execution also exposed PowerShell 5.1's different empty-JSON-array pipeline behavior; the fallback now keeps only entries with a run status before deciding pending versus missing. A real MSBuild property evaluation confirms the engine version resolves to 1.8.4, matching the current app version. +## FAM-003 diagnosis + +Ranked hypotheses before the disposable Publish fixture: + +1. The bundler returns early when a versioned archive already exists. +2. Publish ignores bundler failures and can report success with an incomplete source deliverable. +3. Native file replacement or source discovery can fail after damaging the previous archive. + +The fixture clones the actual BundleSource target into a minimal disposable MSBuild project and uses the actual bundler. Its build artifact copies a tracked input, making paired binary/source freshness directly observable across same-version Publish runs. It also tests destination locks and tracked Unicode paths without publishing the app. + +Red: the second same-version Publish copied the changed source into its build artifact while the ZIP retained the first source. An adjacent fixture also showed quoted Unicode git paths could prevent the first archive from being created while Publish still returned success. The repair always creates a fresh staged archive, publishes it atomically, reports source-discovery failures and lets bundler failure fail the actual Publish target. + +Initial green: actual repeated Publish refreshes both fixture build and source content. A locked destination makes MSBuild return failure while the prior ZIP hash remains unchanged; retry succeeds after the lock is released. Tracked Unicode filenames survive, neighboring output artifacts remain intact and an empty tracked-source set fails explicitly. Native interruption behavior beyond the controlled lock case remains a separate lab gate. + ## Current evidence | Finding | Local commit | Focused evidence | From a51c409c26f5576f158c25f825dcffcb559081c7 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Tue, 20 Oct 2026 15:49:34 -0700 Subject: [PATCH 032/333] v1.8.4: Keep closed accent pickers out of keyboard navigation --- CHANGELOG.md | 1 + scan-landing/about.html | 2 +- scan-landing/help.html | 2 +- scan-landing/index.html | 2 +- scan-landing/ks.js | 9 +++++++-- scan-landing/mcp.html | 2 +- scan-landing/technical.html | 2 +- validation/adversarial-remediation-20261020.md | 12 ++++++++++++ 8 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd769bd8..9c6e3c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Clarified upload throughput as sent payload, including in-flight requests, with a valid acknowledgment required. ### Fixed +- Closed website accent pickers stay out of keyboard and accessibility navigation, and return focus to their trigger when dismissed. - Repeated same-version Publish refreshes the source ZIP and fails clearly if the archive cannot be replaced. - Release monitoring includes KillerScan.Engine on NuGet and reports missing or failed publication. - Release dependency checks stop on restore or audit failure, malformed results, missing engine coverage and vulnerable packages. diff --git a/scan-landing/about.html b/scan-landing/about.html index ca224d24..cf739c6e 100644 --- a/scan-landing/about.html +++ b/scan-landing/about.html @@ -217,7 +217,7 @@

Also try...

- + - + - + + diff --git a/scan-landing/technical.html b/scan-landing/technical.html index 4b628eac..6ad9f94a 100644 --- a/scan-landing/technical.html +++ b/scan-landing/technical.html @@ -494,7 +494,7 @@

Glossary

- + + - + + + + - + - - + + + + - + diff --git a/scan-landing/shortcuts.generated.js b/scan-landing/shortcuts.generated.js index 9dffa77b..2e85fd45 100644 --- a/scan-landing/shortcuts.generated.js +++ b/scan-landing/shortcuts.generated.js @@ -1,2 +1,2 @@ // Generated by build/shortcut-catalog.py from Shell/Shortcuts.cs and Strings. -window.KS_SHORTCUTS = [{"gesture":"F6","category":"Views","labels":{"bn":"ডিভাইস","cs-CZ":"Zařízení","de-DE":"Geräte","en-US":"Devices","es":"Dispositivos","fr-FR":"Appareils","hu-HU":"Eszközök","it-IT":"Dispositivi","ja-JP":"デバイス","kk-KZ":"Құрылғылар","nb-NO":"Enheter","pl-PL":"Urządzenia","pt-BR":"Dispositivos","ru-RU":"Устройства","tr-TR":"Cihazlar","uk-UA":"Пристрої","vi-VN":"Thiết bị","zh-CN":"设备","zh-TW":"裝置"}},{"gesture":"F7","category":"Views","labels":{"bn":"সেবাসমূহ","cs-CZ":"Služby","de-DE":"Dienste","en-US":"Services","es":"Servicios","fr-FR":"Services","hu-HU":"Szolgáltatások","it-IT":"Servizi","ja-JP":"サービス","kk-KZ":"Қызметтер","nb-NO":"Tjenester","pl-PL":"Usługi","pt-BR":"Serviços","ru-RU":"Службы","tr-TR":"Hizmetler","uk-UA":"Служби","vi-VN":"Dịch vụ","zh-CN":"服务","zh-TW":"服務"}},{"gesture":"F8","category":"Views","labels":{"bn":"নেটওয়ার্ক টপোলজি দেখান বা লুকান","cs-CZ":"Přepnout topologii sítě","de-DE":"Netzwerktopologie umschalten","en-US":"Toggle network topology","es":"Alternar topología de red","fr-FR":"Afficher ou masquer la topologie du réseau","hu-HU":"Hálózati topológia megjelenítése","it-IT":"Mostra o nascondi la topologia di rete","ja-JP":"ネットワーク トポロジの表示を切り替えます","kk-KZ":"Желі топологиясын көрсету немесе жасыру","nb-NO":"Slå nettverkstopologi av eller på","pl-PL":"Przełącz topologię sieci","pt-BR":"Alternar topologia da rede","ru-RU":"Показать или скрыть топологию сети","tr-TR":"Ağ topolojisini aç veya kapat","uk-UA":"Показати або сховати топологію мережі","vi-VN":"Bật hoặc tắt sơ đồ mạng","zh-CN":"切换网络拓扑","zh-TW":"切換網路拓撲"}},{"gesture":"F9","category":"Views","labels":{"bn":"সংযোগ পর্যবেক্ষণ","cs-CZ":"Sledování připojení","de-DE":"Verbindungsüberwachung","en-US":"Keep Alive","es":"Supervisión de conexión","fr-FR":"Surveillance de connexion","hu-HU":"Kapcsolatfigyelés","it-IT":"Monitoraggio connessione","ja-JP":"接続監視","kk-KZ":"Қосылымды бақылау","nb-NO":"Keep Alive","pl-PL":"Monitorowanie połączenia","pt-BR":"Keep Alive","ru-RU":"Наблюдение за соединением","tr-TR":"Bağlantı izleme","uk-UA":"Keep Alive","vi-VN":"Keep Alive","zh-CN":"连接监控","zh-TW":"連線監控"}},{"gesture":"F10","category":"Views","labels":{"bn":"টার্মিনাল","cs-CZ":"Terminál","de-DE":"Terminal","en-US":"Terminal","es":"Terminal","fr-FR":"Terminal","hu-HU":"Terminál","it-IT":"Terminale","ja-JP":"ターミナル","kk-KZ":"Терминал","nb-NO":"Terminal","pl-PL":"Terminal","pt-BR":"Terminal","ru-RU":"Терминал","tr-TR":"Terminal","uk-UA":"Термінал","vi-VN":"Terminal","zh-CN":"终端","zh-TW":"終端機"}},{"gesture":"Ctrl + T","category":"Views","labels":{"bn":"ডিভাইস দেখান","cs-CZ":"Zobrazit zařízení","de-DE":"Geräte anzeigen","en-US":"Show Devices","es":"Mostrar dispositivos","fr-FR":"Afficher les appareils","hu-HU":"Eszközök megjelenítése","it-IT":"Mostra dispositivi","ja-JP":"デバイスを表示","kk-KZ":"Құрылғыларды көрсету","nb-NO":"Vis Enheter","pl-PL":"Pokaż urządzenia","pt-BR":"Mostrar Dispositivos","ru-RU":"Показать устройства","tr-TR":"Cihazları göster","uk-UA":"Показати пристрої","vi-VN":"Hiện Thiết bị","zh-CN":"显示设备","zh-TW":"顯示裝置"}},{"gesture":"Ctrl + Shift + T","category":"Views","labels":{"bn":"টার্মিনাল দেখান","cs-CZ":"Zobrazit terminál","de-DE":"Terminal anzeigen","en-US":"Show Terminal","es":"Mostrar terminal","fr-FR":"Afficher le terminal","hu-HU":"Terminál megjelenítése","it-IT":"Mostra terminale","ja-JP":"ターミナルを表示","kk-KZ":"Терминалды көрсету","nb-NO":"Vis Terminal","pl-PL":"Pokaż terminal","pt-BR":"Mostrar Terminal","ru-RU":"Показать терминал","tr-TR":"Terminali göster","uk-UA":"Показати термінал","vi-VN":"Hiện Terminal","zh-CN":"显示终端","zh-TW":"顯示終端機"}},{"gesture":"Ctrl + H","category":"Views","labels":{"bn":"স্ক্যান ইতিহাস","cs-CZ":"Historie skenování","de-DE":"Scanverlauf","en-US":"Scan History","es":"Historial de análisis","fr-FR":"Historique des analyses","hu-HU":"Vizsgálati előzmények","it-IT":"Cronologia scansioni","ja-JP":"スキャン履歴","kk-KZ":"Сканерлеу тарихы","nb-NO":"Skanneloggen","pl-PL":"Historia skanowania","pt-BR":"Histórico de verificações","ru-RU":"История сканирования","tr-TR":"Tarama geçmişi","uk-UA":"Історія сканувань","vi-VN":"Lịch sử quét","zh-CN":"扫描历史","zh-TW":"掃描歷史"}},{"gesture":"Ctrl + Alt + R","category":"Views","labels":{"bn":"ইতিহাসের সেটিংস","cs-CZ":"Nastavení historie","de-DE":"Verlaufseinstellungen","en-US":"History settings","es":"Configuración del historial","fr-FR":"Paramètres de l'historique","hu-HU":"Előzmények beállításai","it-IT":"Impostazioni cronologia","ja-JP":"履歴設定","kk-KZ":"Тарих параметрлері","nb-NO":"Historikkinnstillinger","pl-PL":"Ustawienia historii","pt-BR":"Configurações do histórico","ru-RU":"Настройки истории","tr-TR":"Geçmiş ayarları","uk-UA":"Налаштування історії","vi-VN":"Cài đặt lịch sử","zh-CN":"历史设置","zh-TW":"歷史設定"}},{"gesture":"Ctrl + Shift + P","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল","cs-CZ":"Profily skenování","de-DE":"Scanprofile","en-US":"Scan profiles","es":"Perfiles de análisis","fr-FR":"Profils d’analyse","hu-HU":"Vizsgálati profilok","it-IT":"Profili di scansione","ja-JP":"スキャンプロファイル","kk-KZ":"Сканерлеу профильдері","nb-NO":"Skanneprofiler","pl-PL":"Profile skanowania","pt-BR":"Perfis de verificação","ru-RU":"Профили сканирования","tr-TR":"Tarama profilleri","uk-UA":"Профілі сканування","vi-VN":"Hồ sơ quét","zh-CN":"扫描配置","zh-TW":"掃描設定檔"}},{"gesture":"F4","category":"Views","labels":{"bn":"KillerSpeed","cs-CZ":"KillerSpeed","de-DE":"KillerSpeed","en-US":"KillerSpeed","es":"KillerSpeed","fr-FR":"KillerSpeed","hu-HU":"KillerSpeed","it-IT":"KillerSpeed","ja-JP":"KillerSpeed","kk-KZ":"KillerSpeed","nb-NO":"KillerSpeed","pl-PL":"KillerSpeed","pt-BR":"KillerSpeed","ru-RU":"KillerSpeed","tr-TR":"KillerSpeed","uk-UA":"KillerSpeed","vi-VN":"KillerSpeed","zh-CN":"KillerSpeed","zh-TW":"KillerSpeed"}},{"gesture":"F5","category":"Scan","labels":{"bn":"স্ক্যান শুরু বা বন্ধ করুন","cs-CZ":"Spustit nebo zastavit skenování","de-DE":"Scan starten oder stoppen","en-US":"Start or stop a scan","es":"Iniciar o detener un escaneo","fr-FR":"Démarrer ou arrêter une analyse","hu-HU":"Indítsa el vagy állítsa le a keresést","it-IT":"Avvia o interrompi una scansione","ja-JP":"スキャンの開始または停止","kk-KZ":"Сканерлеуді бастау немесе тоқтату","nb-NO":"Start eller stopp en skanning","pl-PL":"Rozpocznij lub zatrzymaj skanowanie","pt-BR":"Iniciar ou parar uma verificação","ru-RU":"Запустить или остановить сканирование","tr-TR":"Taramayı başlat veya durdur","uk-UA":"Почати або зупинити сканування","vi-VN":"Bắt đầu hoặc dừng quét","zh-CN":"开始或停止扫描","zh-TW":"開始或停止掃描"}},{"gesture":"Esc","category":"Scan","labels":{"bn":"স্ক্যান বাতিল করুন, বা এটি বন্ধ করুন","cs-CZ":"Zrušit skenování nebo zavřít toto okno","de-DE":"Scan abbrechen oder dies schließen","en-US":"Cancel the scan, or close this","es":"Cancelar el escaneo o cerrar esto","fr-FR":"Annuler l'analyse, ou fermer ceci","hu-HU":"Szakítsa meg a beolvasást, vagy zárja be","it-IT":"Annulla la scansione, o chiudi questa finestra","ja-JP":"スキャンの取り消し、またはこれを閉じる","kk-KZ":"Сканерлеуді болдырмау немесе осыны жабу","nb-NO":"Avbryt skanningen, eller lukk dette","pl-PL":"Anuluj skanowanie lub zamknij to okno","pt-BR":"Cancelar a verificação ou fechar isto","ru-RU":"Отменить сканирование или закрыть это окно","tr-TR":"Taramayı iptal et veya bunu kapat","uk-UA":"Скасувати сканування або закрити це вікно","vi-VN":"Hủy quét hoặc đóng cửa sổ này","zh-CN":"取消扫描,或关闭此窗口","zh-TW":"取消掃描,或關閉此視窗"}},{"gesture":"Ctrl + R","category":"Scan","labels":{"bn":"নির্বাচিত হোস্ট গভীরভাবে পুনরায় স্ক্যান করুন","cs-CZ":"Hloubkově znovu skenovat vybrané hostitele","de-DE":"Ausgewählte Hosts tief erneut scannen","en-US":"Deep rescan the selected hosts","es":"Reescaneo profundo de los hosts seleccionados","fr-FR":"Réanalyser en profondeur les hôtes sélectionnés","hu-HU":"A kiválasztott gazdagépek mélyreható újrakeresése","it-IT":"Scansione approfondita degli host selezionati","ja-JP":"選択したホストを詳細に再スキャン","kk-KZ":"Таңдалған хосттарды терең қайта сканерлеу","nb-NO":"Dypskann de valgte vertene på nytt","pl-PL":"Dokładne ponowne skanowanie wybranych hostów","pt-BR":"Refazer a verificação profunda dos hosts selecionados","ru-RU":"Глубокое пересканирование выбранных хостов","tr-TR":"Seçili ana bilgisayarları derin yeniden tara","uk-UA":"Глибоко пересканувати вибрані хости","vi-VN":"Quét sâu lại các máy chủ đã chọn","zh-CN":"深度重新扫描所选主机","zh-TW":"深度重新掃描所選主機"}},{"gesture":"Ctrl + F","category":"Scan","labels":{"bn":"সাবনেট বাক্সে যান","cs-CZ":"Přejít do pole podsítě","de-DE":"Zum Subnetz-Feld springen","en-US":"Jump to the subnet box","es":"Ir al cuadro de subred","fr-FR":"Aller au champ de sous-réseau","hu-HU":"Ugrás az alhálózati mezőre","it-IT":"Vai alla casella della sottorete","ja-JP":"サブネット入力欄へ移動","kk-KZ":"Ішкі желі өрісіне өту","nb-NO":"Gå til subnettfeltet","pl-PL":"Przejdź do pola podsieci","pt-BR":"Ir para a caixa de sub-rede","ru-RU":"Перейти к полю подсети","tr-TR":"Alt ağ kutusuna git","uk-UA":"Перейти до поля підмережі","vi-VN":"Chuyển đến ô mạng con","zh-CN":"跳转到子网输入框","zh-TW":"跳至子網路輸入框"}},{"gesture":"Ctrl + Shift + F","category":"Scan","labels":{"bn":"ডিভাইস ফিল্টার করুন...","cs-CZ":"Filtrovat zařízení...","de-DE":"Geräte filtern...","en-US":"Filter devices...","es":"Filtrar dispositivos...","fr-FR":"Filtrer les appareils...","hu-HU":"Eszközök szűrése...","it-IT":"Filtra dispositivi...","ja-JP":"デバイスを絞り込み...","kk-KZ":"Құрылғыларды сүзу...","nb-NO":"Filtrer enheter...","pl-PL":"Filtruj urządzenia...","pt-BR":"Filtrar dispositivos...","ru-RU":"Фильтр устройств...","tr-TR":"Cihazları filtrele...","uk-UA":"Фільтр пристроїв...","vi-VN":"Lọc thiết bị...","zh-CN":"筛选设备...","zh-TW":"篩選裝置..."}},{"gesture":"Ctrl + A","category":"Scan","labels":{"bn":"সব ডিভাইস নির্বাচন করুন","cs-CZ":"Vybrat všechna zařízení","de-DE":"Alle Geräte auswählen","en-US":"Select every device","es":"Seleccionar todos los dispositivos","fr-FR":"Sélectionner tous les appareils","hu-HU":"Válassza ki az összes eszközt","it-IT":"Seleziona tutti i dispositivi","ja-JP":"すべてのデバイスを選択","kk-KZ":"Барлық құрылғыны таңдау","nb-NO":"Merk alle enheter","pl-PL":"Zaznacz wszystkie urządzenia","pt-BR":"Selecionar todos os dispositivos","ru-RU":"Выбрать все устройства","tr-TR":"Tüm cihazları seç","uk-UA":"Вибрати всі пристрої","vi-VN":"Chọn tất cả thiết bị","zh-CN":"选择所有设备","zh-TW":"選取所有裝置"}},{"gesture":"Ctrl + E","category":"Scan","labels":{"bn":"CSV হিসেবে এক্সপোর্ট","cs-CZ":"Exportovat jako CSV","de-DE":"Als CSV exportieren","en-US":"Export as CSV","es":"Exportar como CSV","fr-FR":"Exporter en CSV","hu-HU":"Exportálás CSV-ként","it-IT":"Esporta come CSV","ja-JP":"CSV としてエクスポート","kk-KZ":"CSV ретінде экспорттау","nb-NO":"Eksporter som CSV","pl-PL":"Eksportuj jako CSV","pt-BR":"Exportar como CSV","ru-RU":"Экспорт в CSV","tr-TR":"CSV olarak dışa aktar","uk-UA":"Експортувати як CSV","vi-VN":"Xuất dạng CSV","zh-CN":"导出为 CSV","zh-TW":"匯出為 CSV"}},{"gesture":"Ctrl + Shift + E","category":"Scan","labels":{"bn":"HTML হিসেবে এক্সপোর্ট","cs-CZ":"Exportovat jako HTML","de-DE":"Als HTML exportieren","en-US":"Export as HTML","es":"Exportar como HTML","fr-FR":"Exporter en HTML","hu-HU":"Exportálás HTML-ként","it-IT":"Esporta come HTML","ja-JP":"HTML としてエクスポート","kk-KZ":"HTML ретінде экспорттау","nb-NO":"Eksporter som HTML","pl-PL":"Eksportuj jako HTML","pt-BR":"Exportar como HTML","ru-RU":"Экспорт в HTML","tr-TR":"HTML olarak dışa aktar","uk-UA":"Експортувати як HTML","vi-VN":"Xuất dạng HTML","zh-CN":"导出为 HTML","zh-TW":"匯出為 HTML"}},{"gesture":"Ctrl + Shift + X","category":"Scan","labels":{"bn":"TXT হিসেবে এক্সপোর্ট","cs-CZ":"Exportovat jako TXT","de-DE":"Als TXT exportieren","en-US":"Export as TXT","es":"Exportar como TXT","fr-FR":"Exporter en TXT","hu-HU":"Exportálás TXT-ként","it-IT":"Esporta come TXT","ja-JP":"TXT としてエクスポート","kk-KZ":"TXT ретінде экспорттау","nb-NO":"Eksporter som TXT","pl-PL":"Eksportuj jako TXT","pt-BR":"Exportar como TXT","ru-RU":"Экспорт в TXT","tr-TR":"TXT olarak dışa aktar","uk-UA":"Експортувати як TXT","vi-VN":"Xuất dạng TXT","zh-CN":"导出为 TXT","zh-TW":"匯出為 TXT"}},{"gesture":"Ctrl + Shift + G","category":"Scan","labels":{"bn":"কার্ড PNG হিসেবে এক্সপোর্ট","cs-CZ":"Exportovat karty jako PNG","de-DE":"Karten als PNG exportieren","en-US":"Export cards as PNG","es":"Exportar tarjetas como PNG","fr-FR":"Exporter les cartes en PNG","hu-HU":"Kártyák exportálása PNG-ként","it-IT":"Esporta le schede come PNG","ja-JP":"カードを PNG にエクスポート","kk-KZ":"Карталарды PNG ретінде экспорттау","nb-NO":"Eksporter kort som PNG","pl-PL":"Eksportuj karty jako PNG","pt-BR":"Exportar cartões como PNG","ru-RU":"Экспорт карточек в PNG","tr-TR":"Kartları PNG olarak dışa aktar","uk-UA":"Експортувати картки як PNG","vi-VN":"Xuất thẻ dạng PNG","zh-CN":"将卡片导出为 PNG","zh-TW":"將卡片匯出為 PNG"}},{"gesture":"Ctrl + Shift + J","category":"Scan","labels":{"bn":"JPG-তে এক্সপোর্ট","cs-CZ":"Exportovat do JPG","de-DE":"Als JPG exportieren","en-US":"Export to JPG","es":"Exportar a JPG","fr-FR":"Exporter en JPG","hu-HU":"Exportálás JPG-be","it-IT":"Esporta in JPG","ja-JP":"JPG にエクスポート","kk-KZ":"JPG форматына экспорттау","nb-NO":"Eksporter til JPG","pl-PL":"Eksportuj do JPG","pt-BR":"Exportar para JPG","ru-RU":"Экспорт в JPG","tr-TR":"JPG olarak dışa aktar","uk-UA":"Експортувати як JPG","vi-VN":"Xuất sang JPG","zh-CN":"导出为 JPG","zh-TW":"匯出為 JPG"}},{"gesture":"Ctrl + Alt + H","category":"Scan","labels":{"bn":"HTML-এ এক্সপোর্ট","cs-CZ":"Exportovat do HTML","de-DE":"Als HTML exportieren","en-US":"Export to HTML","es":"Exportar a HTML","fr-FR":"Exporter en HTML","hu-HU":"Exportálás HTML-be","it-IT":"Esporta in HTML","ja-JP":"HTML にエクスポート","kk-KZ":"HTML форматына экспорттау","nb-NO":"Eksporter til HTML","pl-PL":"Eksportuj do HTML","pt-BR":"Exportar para HTML","ru-RU":"Экспорт в HTML","tr-TR":"HTML olarak dışa aktar","uk-UA":"Експортувати як HTML","vi-VN":"Xuất sang HTML","zh-CN":"导出为 HTML","zh-TW":"匯出為 HTML"}},{"gesture":"Enter","category":"Device","labels":{"bn":"নির্বাচিত ডিভাইস ব্রাউজারে খুলুন","cs-CZ":"Otevřít vybrané zařízení v prohlížeči","de-DE":"Ausgewähltes Gerät im Browser öffnen","en-US":"Open the selected device in a browser","es":"Abrir el dispositivo seleccionado en el navegador","fr-FR":"Ouvrir l'appareil sélectionné dans le navigateur","hu-HU":"Nyissa meg a kiválasztott eszközt egy böngészőben","it-IT":"Apri il dispositivo selezionato in un browser","ja-JP":"選択したデバイスをブラウザーで開く","kk-KZ":"Таңдалған құрылғыны браузерде ашу","nb-NO":"Åpne den valgte enheten i en nettleser","pl-PL":"Otwórz wybrane urządzenie w przeglądarce","pt-BR":"Abrir o dispositivo selecionado em um navegador","ru-RU":"Открыть выбранное устройство в браузере","tr-TR":"Seçili cihazı tarayıcıda aç","uk-UA":"Відкрити вибраний пристрій у браузері","vi-VN":"Mở thiết bị đã chọn trong trình duyệt","zh-CN":"在浏览器中打开所选设备","zh-TW":"在瀏覽器中開啟所選裝置"}},{"gesture":"F3","category":"Device","labels":{"bn":"নির্বাচিত ডিভাইস Keep Alive-এ দেখান","cs-CZ":"Zobrazit vybrané zařízení v Keep Alive","de-DE":"Ausgewähltes Gerät in Keep Alive anzeigen","en-US":"Show selected device in Keep Alive","es":"Mostrar el dispositivo seleccionado en Keep Alive","fr-FR":"Afficher l'appareil sélectionné dans Keep Alive","hu-HU":"Kijelölt eszköz megjelenítése a Keep Alive nézetben","it-IT":"Mostra il dispositivo selezionato in Keep Alive","ja-JP":"選択デバイスを Keep Alive に表示","kk-KZ":"Таңдалған құрылғыны Keep Alive ішінде көрсету","nb-NO":"Vis valgt enhet i Keep Alive","pl-PL":"Pokaż wybrane urządzenie w Keep Alive","pt-BR":"Mostrar o dispositivo selecionado em Keep Alive","ru-RU":"Показать выбранное устройство в Keep Alive","tr-TR":"Seçili cihazı Keep Alive'da göster","uk-UA":"Показати вибраний пристрій у Keep Alive","vi-VN":"Hiện thiết bị đã chọn trong Keep Alive","zh-CN":"在 Keep Alive 中显示选中设备","zh-TW":"在 Keep Alive 中顯示選取裝置"}},{"gesture":"Ctrl + P","category":"Device","labels":{"bn":"নির্বাচিত ডিভাইসে পিং করুন","cs-CZ":"Odeslat ping vybranému zařízení","de-DE":"Ausgewähltes Gerät anpingen","en-US":"Ping the selected device","es":"Hacer ping al dispositivo seleccionado","fr-FR":"Envoyer un ping à l'appareil sélectionné","hu-HU":"Pingelje a kiválasztott eszközt","it-IT":"Esegui il ping del dispositivo selezionato","ja-JP":"選択したデバイスに ping を送信","kk-KZ":"Таңдалған құрылғыға Ping жіберу","nb-NO":"Ping den valgte enheten","pl-PL":"Wyślij ping do wybranego urządzenia","pt-BR":"Fazer ping no dispositivo selecionado","ru-RU":"Выполнить ping выбранного устройства","tr-TR":"Seçili cihaza ping gönder","uk-UA":"Пінгувати вибраний пристрій","vi-VN":"Ping thiết bị đã chọn","zh-CN":"Ping 所选设备","zh-TW":"Ping 所選裝置"}},{"gesture":"Ctrl + D","category":"Device","labels":{"bn":"নির্বাচিত ডিভাইসে রিমোট ডেস্কটপ খুলুন","cs-CZ":"Otevřít Vzdálenou plochu k vybranému zařízení","de-DE":"Remotedesktop zum ausgewählten Gerät öffnen","en-US":"Open Remote Desktop to the selected device","es":"Abrir Escritorio remoto en el dispositivo seleccionado","fr-FR":"Ouvrir le Bureau à distance vers l'appareil sélectionné","hu-HU":"Nyissa meg a Távoli asztalt a kiválasztott eszközön","it-IT":"Apri Desktop remoto verso il dispositivo selezionato","ja-JP":"選択したデバイスにリモート デスクトップで接続","kk-KZ":"Таңдалған құрылғыға қашықтағы жұмыс үстелін ашу","nb-NO":"Åpne Eksternt skrivebord til den valgte enheten","pl-PL":"Otwórz Pulpit zdalny do wybranego urządzenia","pt-BR":"Abrir a Área de Trabalho Remota no dispositivo selecionado","ru-RU":"Открыть удаленный рабочий стол к выбранному устройству","tr-TR":"Seçili cihaza Uzak Masaüstü aç","uk-UA":"Відкрити віддалений робочий стіл на вибраному пристрої","vi-VN":"Mở Remote Desktop tới thiết bị đã chọn","zh-CN":"远程桌面连接到所选设备","zh-TW":"遠端桌面連線到所選裝置"}},{"gesture":"Ctrl + S","category":"Device","labels":{"bn":"SSH দিয়ে নির্বাচিত ডিভাইসে সংযোগ করুন","cs-CZ":"Připojit se k vybranému zařízení přes SSH","de-DE":"Über SSH mit dem ausgewählten Gerät verbinden","en-US":"Connect to the selected device over SSH","es":"Conectar al dispositivo seleccionado por SSH","fr-FR":"Se connecter à l'appareil sélectionné en SSH","hu-HU":"Csatlakozzon a kiválasztott eszközhöz SSH-n keresztül","it-IT":"Connettiti al dispositivo selezionato via SSH","ja-JP":"選択したデバイスに SSH で接続","kk-KZ":"Таңдалған құрылғыға SSH арқылы қосылу","nb-NO":"Koble til den valgte enheten via SSH","pl-PL":"Połącz się z wybranym urządzeniem przez SSH","pt-BR":"Conectar ao dispositivo selecionado por SSH","ru-RU":"Подключиться к выбранному устройству по SSH","tr-TR":"Seçili cihaza SSH ile bağlan","uk-UA":"Підключитися до вибраного пристрою через SSH","vi-VN":"Kết nối với thiết bị đã chọn qua SSH","zh-CN":"通过 SSH 连接到所选设备","zh-TW":"透過 SSH 連線到所選裝置"}},{"gesture":"Ctrl + Shift + S","category":"Device","labels":{"bn":"একটি SSH অ্যাকাউন্ট বেছে নিয়ে সংযোগ করুন","cs-CZ":"Vybrat účet SSH a připojit se","de-DE":"SSH-Konto wählen und verbinden","en-US":"Choose an SSH account and connect","es":"Elegir una cuenta SSH y conectar","fr-FR":"Choisir un compte SSH et se connecter","hu-HU":"Válasszon egy SSH-fiókot, és csatlakozzon","it-IT":"Scegli un account SSH e connettiti","ja-JP":"SSH アカウントを選んで接続","kk-KZ":"SSH тіркелгісін таңдап қосылу","nb-NO":"Velg en SSH-konto og koble til","pl-PL":"Wybierz konto SSH i połącz","pt-BR":"Escolher uma conta SSH e conectar","ru-RU":"Выбрать учетную запись SSH и подключиться","tr-TR":"Bir SSH hesabı seç ve bağlan","uk-UA":"Вибрати обліковий запис SSH і підключитися","vi-VN":"Chọn tài khoản SSH và kết nối","zh-CN":"选择 SSH 账户并连接","zh-TW":"選擇 SSH 帳戶並連線"}},{"gesture":"Ctrl + C","category":"Device","labels":{"bn":"নির্বাচিত IP ঠিকানা কপি করুন","cs-CZ":"Kopírovat vybranou IP adresu","de-DE":"Ausgewählte IP-Adresse kopieren","en-US":"Copy the selected IP address","es":"Copiar la dirección IP seleccionada","fr-FR":"Copier l'adresse IP sélectionnée","hu-HU":"Másolja a kiválasztott IP-címet","it-IT":"Copia l'indirizzo IP selezionato","ja-JP":"選択した IP アドレスをコピー","kk-KZ":"Таңдалған IP мекенжайын көшіру","nb-NO":"Kopier den valgte IP-adressen","pl-PL":"Kopiuj wybrany adres IP","pt-BR":"Copiar o endereço IP selecionado","ru-RU":"Копировать выбранный IP-адрес","tr-TR":"Seçili IP adresini kopyala","uk-UA":"Копіювати вибрану IP-адресу","vi-VN":"Sao chép địa chỉ IP đã chọn","zh-CN":"复制所选 IP 地址","zh-TW":"複製所選 IP 位址"}},{"gesture":"Ctrl + Shift + C","category":"Device","labels":{"bn":"নির্বাচিত MAC ঠিকানা কপি করুন","cs-CZ":"Kopírovat vybranou MAC adresu","de-DE":"Ausgewählte MAC-Adresse kopieren","en-US":"Copy the selected MAC address","es":"Copiar la dirección MAC seleccionada","fr-FR":"Copier l'adresse MAC sélectionnée","hu-HU":"Másolja a kiválasztott MAC-címet","it-IT":"Copia l'indirizzo MAC selezionato","ja-JP":"選択した MAC アドレスをコピー","kk-KZ":"Таңдалған MAC мекенжайын көшіру","nb-NO":"Kopier den valgte MAC-adressen","pl-PL":"Kopiuj wybrany adres MAC","pt-BR":"Copiar o endereço MAC selecionado","ru-RU":"Копировать выбранный MAC-адрес","tr-TR":"Seçili MAC adresini kopyala","uk-UA":"Копіювати вибрану MAC-адресу","vi-VN":"Sao chép địa chỉ MAC đã chọn","zh-CN":"复制所选 MAC 地址","zh-TW":"複製所選 MAC 位址"}},{"gesture":"Ctrl + Alt + C","category":"Device","labels":{"bn":"নির্বাচিত হোস্টনাম কপি করুন","cs-CZ":"Kopírovat vybraný název hostitele","de-DE":"Ausgewählten Hostnamen kopieren","en-US":"Copy the selected hostname","es":"Copiar el nombre de host seleccionado","fr-FR":"Copier le nom d'hôte sélectionné","hu-HU":"Másolja ki a kiválasztott gazdagépnevet","it-IT":"Copia il nome host selezionato","ja-JP":"選択したホスト名をコピー","kk-KZ":"Таңдалған хост атауын көшіру","nb-NO":"Kopier det valgte vertsnavnet","pl-PL":"Kopiuj wybraną nazwę hosta","pt-BR":"Copiar o nome do host selecionado","ru-RU":"Копировать выбранное имя хоста","tr-TR":"Seçili ana bilgisayar adını kopyala","uk-UA":"Копіювати вибране ім'я хоста","vi-VN":"Sao chép tên máy chủ đã chọn","zh-CN":"复制所选主机名","zh-TW":"複製所選主機名稱"}},{"gesture":"Shift + F10","category":"Device","labels":{"bn":"প্রসঙ্গ মেনু","cs-CZ":"Kontextová nabídka","de-DE":"Kontextmenü","en-US":"Context menu","es":"Menú contextual","fr-FR":"Menu contextuel","hu-HU":"Helyi menü","it-IT":"Menu contestuale","ja-JP":"コンテキストメニュー","kk-KZ":"Контекстік мәзір","nb-NO":"Kontekstmeny","pl-PL":"Menu kontekstowe","pt-BR":"Menu de contexto","ru-RU":"Контекстное меню","tr-TR":"Bağlam menüsü","uk-UA":"Контекстне меню","vi-VN":"Menu ngữ cảnh","zh-CN":"上下文菜单","zh-TW":"快顯功能表"}},{"gesture":"Menu","category":"Device","labels":{"bn":"প্রসঙ্গ মেনু","cs-CZ":"Kontextová nabídka","de-DE":"Kontextmenü","en-US":"Context menu","es":"Menú contextual","fr-FR":"Menu contextuel","hu-HU":"Helyi menü","it-IT":"Menu contestuale","ja-JP":"コンテキストメニュー","kk-KZ":"Контекстік мәзір","nb-NO":"Kontekstmeny","pl-PL":"Menu kontekstowe","pt-BR":"Menu de contexto","ru-RU":"Контекстное меню","tr-TR":"Bağlam menüsü","uk-UA":"Контекстне меню","vi-VN":"Menu ngữ cảnh","zh-CN":"上下文菜单","zh-TW":"快顯功能表"}},{"gesture":"F2","category":"Device","labels":{"bn":"ডিভাইসের নাম পরিবর্তন করুন","cs-CZ":"Přejmenovat zařízení","de-DE":"Gerät umbenennen","en-US":"Rename device","es":"Cambiar nombre del dispositivo","fr-FR":"Renommer l’appareil","hu-HU":"Eszköz átnevezése","it-IT":"Rinomina dispositivo","ja-JP":"デバイス名を変更","kk-KZ":"Құрылғы атын өзгерту","nb-NO":"Gi enheten nytt navn","pl-PL":"Zmień nazwę urządzenia","pt-BR":"Renomear dispositivo","ru-RU":"Переименовать устройство","tr-TR":"Cihazı yeniden adlandır","uk-UA":"Перейменувати пристрій","vi-VN":"Đổi tên thiết bị","zh-CN":"重命名设备","zh-TW":"重新命名裝置"}},{"gesture":"Ctrl + Alt + N","category":"Device","labels":{"bn":"ম্যানুয়াল নাম মুছুন","cs-CZ":"Vymazat ruční název","de-DE":"Manuellen Namen löschen","en-US":"Clear manual name","es":"Borrar nombre manual","fr-FR":"Effacer le nom manuel","hu-HU":"Kézi név törlése","it-IT":"Cancella nome manuale","ja-JP":"手動名を消去","kk-KZ":"Қолмен берілген атауды тазалау","nb-NO":"Fjern manuelt navn","pl-PL":"Wyczyść nazwę ręczną","pt-BR":"Limpar nome manual","ru-RU":"Удалить заданное имя","tr-TR":"Elle verilen adı temizle","uk-UA":"Скинути ім'я, задане вручну","vi-VN":"Xóa tên thủ công","zh-CN":"清除手动名称","zh-TW":"清除手動名稱"}},{"gesture":"Ctrl + Alt + T","category":"Device","labels":{"bn":"ওভাররাইড সাফ করুন","cs-CZ":"Zrušit ruční nastavení","de-DE":"Überschreibung löschen","en-US":"Clear Override","es":"Borrar anulación","fr-FR":"Effacer le remplacement","hu-HU":"Felülbírálás törlése","it-IT":"Rimuovi override","ja-JP":"上書きをクリア","kk-KZ":"Қайта анықтауды тазалау","nb-NO":"Fjern overstyring","pl-PL":"Usuń nadpisanie","pt-BR":"Limpar substituição","ru-RU":"Сбросить переопределение","tr-TR":"Geçersiz kılmayı temizle","uk-UA":"Скинути заміну","vi-VN":"Xóa ghi đè","zh-CN":"清除覆盖","zh-TW":"清除覆寫"}},{"gesture":"Ctrl + Shift + B","category":"Device","labels":{"bn":"বিশ্বস্ত ডিভাইস","cs-CZ":"Důvěryhodné zařízení","de-DE":"Vertrauenswürdiges Gerät","en-US":"Trusted device","es":"Dispositivo de confianza","fr-FR":"Appareil approuvé","hu-HU":"Megbízható eszköz","it-IT":"Dispositivo attendibile","ja-JP":"信頼済みデバイス","kk-KZ":"Сенімді құрылғы","nb-NO":"Klarert enhet","pl-PL":"Zaufane urządzenie","pt-BR":"Dispositivo confiável","ru-RU":"Доверенное устройство","tr-TR":"Güvenilir cihaz","uk-UA":"Довірений пристрій","vi-VN":"Thiết bị đáng tin cậy","zh-CN":"可信设备","zh-TW":"信任的裝置"}},{"gesture":"Ctrl + Shift + C","category":"Device","labels":{"bn":"সেবাসমূহ: ঠিকানা ও পোর্ট কপি করুন","cs-CZ":"Služby: Kopírovat adresu a port","de-DE":"Dienste: Adresse und Port kopieren","en-US":"Services: Copy endpoint","es":"Servicios: Copiar dirección y puerto","fr-FR":"Services: Copier l'adresse et le port","hu-HU":"Szolgáltatások: Cím és port másolása","it-IT":"Servizi: Copia indirizzo e porta","ja-JP":"サービス: アドレスとポートをコピー","kk-KZ":"Қызметтер: Мекенжай мен портты көшіру","nb-NO":"Tjenester: Kopier adresse og port","pl-PL":"Usługi: Kopiuj adres i port","pt-BR":"Serviços: Copiar endereço e porta","ru-RU":"Службы: Копировать адрес и порт","tr-TR":"Hizmetler: Adresi ve bağlantı noktasını kopyala","uk-UA":"Служби: Копіювати адресу й порт","vi-VN":"Dịch vụ: Sao chép địa chỉ và cổng","zh-CN":"服务: 复制地址和端口","zh-TW":"服務: 複製位址與連接埠"}},{"gesture":"Ctrl + Shift + Y","category":"Device","labels":{"bn":"সেবাসমূহ: সেবার নাম কপি করুন","cs-CZ":"Služby: Kopírovat název služby","de-DE":"Dienste: Dienstnamen kopieren","en-US":"Services: Copy service name","es":"Servicios: Copiar nombre del servicio","fr-FR":"Services: Copier le nom du service","hu-HU":"Szolgáltatások: Szolgáltatásnév másolása","it-IT":"Servizi: Copia nome del servizio","ja-JP":"サービス: サービス名をコピー","kk-KZ":"Қызметтер: Қызмет атауын көшіру","nb-NO":"Tjenester: Kopier tjenestenavn","pl-PL":"Usługi: Kopiuj nazwę usługi","pt-BR":"Serviços: Copiar nome do serviço","ru-RU":"Службы: Копировать имя службы","tr-TR":"Hizmetler: Hizmet adını kopyala","uk-UA":"Служби: Копіювати назву служби","vi-VN":"Dịch vụ: Sao chép tên dịch vụ","zh-CN":"服务: 复制服务名称","zh-TW":"服務: 複製服務名稱"}},{"gesture":"Ctrl + Alt + P","category":"Device","labels":{"bn":"সেবাসমূহ: পোর্ট কপি করুন","cs-CZ":"Služby: Kopírovat port","de-DE":"Dienste: Port kopieren","en-US":"Services: Copy port","es":"Servicios: Copiar puerto","fr-FR":"Services: Copier le port","hu-HU":"Szolgáltatások: Port másolása","it-IT":"Servizi: Copia porta","ja-JP":"サービス: ポートをコピー","kk-KZ":"Қызметтер: Портты көшіру","nb-NO":"Tjenester: Kopier port","pl-PL":"Usługi: Kopiuj port","pt-BR":"Serviços: Copiar porta","ru-RU":"Службы: Копировать порт","tr-TR":"Hizmetler: Bağlantı noktasını kopyala","uk-UA":"Служби: Копіювати порт","vi-VN":"Dịch vụ: Sao chép cổng","zh-CN":"服务: 复制端口","zh-TW":"服務: 複製連接埠"}},{"gesture":"Ctrl + Shift + H","category":"Views","labels":{"bn":"স্ক্যান ইতিহাস: ইতিহাসের দৃশ্য বদলান","cs-CZ":"Historie skenování: Přepnout zobrazení historie","de-DE":"Scanverlauf: Verlaufsansicht wechseln","en-US":"Scan History: Switch history view","es":"Historial de análisis: Cambiar vista del historial","fr-FR":"Historique des analyses: Changer la vue de l’historique","hu-HU":"Vizsgálati előzmények: Előzmények nézetének váltása","it-IT":"Cronologia scansioni: Cambia vista della cronologia","ja-JP":"スキャン履歴: 履歴表示を切り替え","kk-KZ":"Сканерлеу тарихы: Тарих көрінісін ауыстыру","nb-NO":"Skanneloggen: Bytt historikkvisning","pl-PL":"Historia skanowania: Przełącz widok historii","pt-BR":"Histórico de verificações: Alternar a visualização do histórico","ru-RU":"История сканирования: Переключить вид истории","tr-TR":"Tarama geçmişi: Geçmiş görünümünü değiştir","uk-UA":"Історія сканувань: Змінити вигляд історії","vi-VN":"Lịch sử quét: Chuyển chế độ xem lịch sử","zh-CN":"扫描历史: 切换历史记录视图","zh-TW":"掃描歷史: 切換歷史記錄檢視"}},{"gesture":"Ctrl + Shift + Y","category":"Device","labels":{"bn":"স্ক্যান ইতিহাস: সংরক্ষিত ডিভাইসের বিবরণ কপি করুন","cs-CZ":"Historie skenování: Kopírovat uložené údaje zařízení","de-DE":"Scanverlauf: Gespeicherte Gerätedetails kopieren","en-US":"Scan History: Copy saved device details","es":"Historial de análisis: Copiar detalles del dispositivo guardado","fr-FR":"Historique des analyses: Copier les détails du périphérique enregistré","hu-HU":"Vizsgálati előzmények: Mentett eszközadatok másolása","it-IT":"Cronologia scansioni: Copia i dettagli del dispositivo salvato","ja-JP":"スキャン履歴: 保存されたデバイス情報をコピー","kk-KZ":"Сканерлеу тарихы: Сақталған құрылғы мәліметтерін көшіру","nb-NO":"Skanneloggen: Kopier lagrede enhetsdetaljer","pl-PL":"Historia skanowania: Kopiuj zapisane dane urządzenia","pt-BR":"Histórico de verificações: Copiar detalhes do dispositivo salvo","ru-RU":"История сканирования: Копировать сохранённые сведения об устройстве","tr-TR":"Tarama geçmişi: Kayıtlı cihaz ayrıntılarını kopyala","uk-UA":"Історія сканувань: Копіювати збережені відомості про пристрій","vi-VN":"Lịch sử quét: Sao chép chi tiết thiết bị đã lưu","zh-CN":"扫描历史: 复制已保存的设备详情","zh-TW":"掃描歷史: 複製已儲存的裝置詳細資料"}},{"gesture":"Enter","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: প্রোফাইল চালান","cs-CZ":"Profily skenování: Spustit profil","de-DE":"Scanprofile: Profil ausführen","en-US":"Scan profiles: Run profile","es":"Perfiles de análisis: Ejecutar perfil","fr-FR":"Profils d’analyse: Exécuter le profil","hu-HU":"Vizsgálati profilok: Profil futtatása","it-IT":"Profili di scansione: Esegui profilo","ja-JP":"スキャンプロファイル: プロファイルを実行","kk-KZ":"Сканерлеу профильдері: Профильді іске қосу","nb-NO":"Skanneprofiler: Kjør profil","pl-PL":"Profile skanowania: Uruchom profil","pt-BR":"Perfis de verificação: Executar perfil","ru-RU":"Профили сканирования: Запустить профиль","tr-TR":"Tarama profilleri: Profili çalıştır","uk-UA":"Профілі сканування: Запустити профіль","vi-VN":"Hồ sơ quét: Chạy hồ sơ","zh-CN":"扫描配置: 运行配置","zh-TW":"掃描設定檔: 執行設定檔"}},{"gesture":"Ctrl + L","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: লক্ষ্য লোড করুন","cs-CZ":"Profily skenování: Načíst cíl","de-DE":"Scanprofile: Ziel laden","en-US":"Scan profiles: Load target","es":"Perfiles de análisis: Cargar destino","fr-FR":"Profils d’analyse: Charger la cible","hu-HU":"Vizsgálati profilok: Cél betöltése","it-IT":"Profili di scansione: Carica destinazione","ja-JP":"スキャンプロファイル: 対象を読み込む","kk-KZ":"Сканерлеу профильдері: Нысанды жүктеу","nb-NO":"Skanneprofiler: Last inn mål","pl-PL":"Profile skanowania: Wczytaj cel","pt-BR":"Perfis de verificação: Carregar destino","ru-RU":"Профили сканирования: Загрузить цель","tr-TR":"Tarama profilleri: Hedefi yükle","uk-UA":"Профілі сканування: Завантажити ціль","vi-VN":"Hồ sơ quét: Tải mục tiêu","zh-CN":"扫描配置: 加载目标","zh-TW":"掃描設定檔: 載入目標"}},{"gesture":"Delete","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: প্রোফাইল মুছুন","cs-CZ":"Profily skenování: Odstranit profil","de-DE":"Scanprofile: Profil löschen","en-US":"Scan profiles: Delete profile","es":"Perfiles de análisis: Eliminar perfil","fr-FR":"Profils d’analyse: Supprimer le profil","hu-HU":"Vizsgálati profilok: Profil törlése","it-IT":"Profili di scansione: Elimina profilo","ja-JP":"スキャンプロファイル: プロファイルを削除","kk-KZ":"Сканерлеу профильдері: Профильді жою","nb-NO":"Skanneprofiler: Slett profil","pl-PL":"Profile skanowania: Usuń profil","pt-BR":"Perfis de verificação: Excluir perfil","ru-RU":"Профили сканирования: Удалить профиль","tr-TR":"Tarama profilleri: Profili sil","uk-UA":"Профілі сканування: Видалити профіль","vi-VN":"Hồ sơ quét: Xóa hồ sơ","zh-CN":"扫描配置: 删除配置","zh-TW":"掃描設定檔: 刪除設定檔"}},{"gesture":"Ctrl + Alt + D","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: পরে সব গভীর স্ক্যান করুন","cs-CZ":"Profily skenování: Poté hloubkově skenovat vše","de-DE":"Scanprofile: Danach Tiefenscan für alle","en-US":"Scan profiles: Deep Scan All afterward","es":"Perfiles de análisis: Análisis profundo después","fr-FR":"Profils d’analyse: Analyse approfondie ensuite","hu-HU":"Vizsgálati profilok: Utána teljes mélyvizsgálat","it-IT":"Profili di scansione: Scansione profonda dopo","ja-JP":"スキャンプロファイル: 終了後にすべてを詳細スキャン","kk-KZ":"Сканерлеу профильдері: Содан кейін толық терең сканерлеу","nb-NO":"Skanneprofiler: Dypskann alle etterpå","pl-PL":"Profile skanowania: Potem głęboki skan wszystkiego","pt-BR":"Perfis de verificação: Verificação Profunda de Todos depois","ru-RU":"Профили сканирования: Затем глубокое сканирование всех","tr-TR":"Tarama profilleri: Ardından tümünü derin tara","uk-UA":"Профілі сканування: Потім глибоке сканування всіх","vi-VN":"Hồ sơ quét: Sau đó quét sâu tất cả","zh-CN":"扫描配置: 随后深度扫描全部","zh-TW":"掃描設定檔: 之後深度掃描全部"}},{"gesture":"Ctrl + Alt + S","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: বর্তমান প্রোফাইল সংরক্ষণ করুন","cs-CZ":"Profily skenování: Uložit aktuální profil","de-DE":"Scanprofile: Aktuelles Profil speichern","en-US":"Scan profiles: Save current profile","es":"Perfiles de análisis: Guardar perfil actual","fr-FR":"Profils d’analyse: Enregistrer le profil actuel","hu-HU":"Vizsgálati profilok: Jelenlegi profil mentése","it-IT":"Profili di scansione: Salva profilo corrente","ja-JP":"スキャンプロファイル: 現在のプロファイルを保存","kk-KZ":"Сканерлеу профильдері: Ағымдағы профильді сақтау","nb-NO":"Skanneprofiler: Lagre gjeldende profil","pl-PL":"Profile skanowania: Zapisz bieżący profil","pt-BR":"Perfis de verificação: Salvar perfil atual","ru-RU":"Профили сканирования: Сохранить текущий профиль","tr-TR":"Tarama profilleri: Geçerli profili kaydet","uk-UA":"Профілі сканування: Зберегти поточний профіль","vi-VN":"Hồ sơ quét: Lưu hồ sơ hiện tại","zh-CN":"扫描配置: 保存当前配置","zh-TW":"掃描設定檔: 儲存目前設定檔"}},{"gesture":"Ctrl + C","category":"Device","labels":{"bn":"সংযোগ পর্যবেক্ষণ: ঠিকানা কপি করুন","cs-CZ":"Sledování připojení: Kopírovat adresu","de-DE":"Verbindungsüberwachung: Adresse kopieren","en-US":"Keep Alive: Copy address","es":"Supervisión de conexión: Copiar dirección","fr-FR":"Surveillance de connexion: Copier l'adresse","hu-HU":"Kapcsolatfigyelés: Cím másolása","it-IT":"Monitoraggio connessione: Copia indirizzo","ja-JP":"接続監視: アドレスをコピー","kk-KZ":"Қосылымды бақылау: Мекенжайды көшіру","nb-NO":"Keep Alive: Kopier adresse","pl-PL":"Monitorowanie połączenia: Kopiuj adres","pt-BR":"Keep Alive: Copiar endereço","ru-RU":"Наблюдение за соединением: Копировать адрес","tr-TR":"Bağlantı izleme: Adresi kopyala","uk-UA":"Keep Alive: Копіювати адресу","vi-VN":"Keep Alive: Sao chép địa chỉ","zh-CN":"连接监控: 复制地址","zh-TW":"連線監控: 複製位址"}},{"gesture":"F3","category":"Device","labels":{"bn":"সংযোগ পর্যবেক্ষণ: আবার পরীক্ষা করুন","cs-CZ":"Sledování připojení: Zkontrolovat znovu","de-DE":"Verbindungsüberwachung: Erneut prüfen","en-US":"Keep Alive: Check again","es":"Supervisión de conexión: Comprobar de nuevo","fr-FR":"Surveillance de connexion: Vérifier à nouveau","hu-HU":"Kapcsolatfigyelés: Újraellenőrzés","it-IT":"Monitoraggio connessione: Controlla di nuovo","ja-JP":"接続監視: 再確認","kk-KZ":"Қосылымды бақылау: Қайта тексеру","nb-NO":"Keep Alive: Sjekk på nytt","pl-PL":"Monitorowanie połączenia: Sprawdź ponownie","pt-BR":"Keep Alive: Verificar novamente","ru-RU":"Наблюдение за соединением: Проверить снова","tr-TR":"Bağlantı izleme: Yeniden denetle","uk-UA":"Keep Alive: Перевірити знову","vi-VN":"Keep Alive: Kiểm tra lại","zh-CN":"连接监控: 重新检查","zh-TW":"連線監控: 重新檢查"}},{"gesture":"Ctrl + R","category":"Device","labels":{"bn":"সংযোগ পর্যবেক্ষণ: পরিসংখ্যান রিসেট করুন","cs-CZ":"Sledování připojení: Vynulovat statistiky","de-DE":"Verbindungsüberwachung: Statistik zurücksetzen","en-US":"Keep Alive: Reset statistics","es":"Supervisión de conexión: Restablecer estadísticas","fr-FR":"Surveillance de connexion: Réinitialiser les statistiques","hu-HU":"Kapcsolatfigyelés: Statisztika nullázása","it-IT":"Monitoraggio connessione: Azzera statistiche","ja-JP":"接続監視: 統計をリセット","kk-KZ":"Қосылымды бақылау: Статистиканы қалпына келтіру","nb-NO":"Keep Alive: Tilbakestill statistikk","pl-PL":"Monitorowanie połączenia: Zeruj statystyki","pt-BR":"Keep Alive: Redefinir estatísticas","ru-RU":"Наблюдение за соединением: Сбросить статистику","tr-TR":"Bağlantı izleme: İstatistikleri sıfırla","uk-UA":"Keep Alive: Скинути статистику","vi-VN":"Keep Alive: Đặt lại thống kê","zh-CN":"连接监控: 重置统计","zh-TW":"連線監控: 重設統計"}},{"gesture":"Delete","category":"Device","labels":{"bn":"সংযোগ পর্যবেক্ষণ: দেখা বন্ধ করুন","cs-CZ":"Sledování připojení: Přestat sledovat","de-DE":"Verbindungsüberwachung: Nicht mehr überwachen","en-US":"Keep Alive: Stop watching this target","es":"Supervisión de conexión: Dejar de supervisar este destino","fr-FR":"Surveillance de connexion: Ne plus surveiller cette cible","hu-HU":"Kapcsolatfigyelés: Cél figyelésének leállítása","it-IT":"Monitoraggio connessione: Smetti di monitorare questa destinazione","ja-JP":"接続監視: この対象の監視を停止","kk-KZ":"Қосылымды бақылау: Бұл нысананы бақылауды тоқтату","nb-NO":"Keep Alive: Slutt å overvåke dette målet","pl-PL":"Monitorowanie połączenia: Przestań monitorować ten cel","pt-BR":"Keep Alive: Parar de monitorar este destino","ru-RU":"Наблюдение за соединением: Прекратить наблюдение за целью","tr-TR":"Bağlantı izleme: Bu hedefi izlemeyi durdur","uk-UA":"Keep Alive: Припинити відстеження цієї цілі","vi-VN":"Keep Alive: Dừng theo dõi mục tiêu này","zh-CN":"连接监控: 停止监视此目标","zh-TW":"連線監控: 停止監看此目標"}},{"gesture":"Ctrl + Shift + C","category":"App","labels":{"bn":"টার্মিনাল: কপি","cs-CZ":"Terminál: Kopírovat","de-DE":"Terminal: Kopieren","en-US":"Terminal: Copy","es":"Terminal: Copiar","fr-FR":"Terminal: Copier","hu-HU":"Terminál: Másolás","it-IT":"Terminale: Copia","ja-JP":"ターミナル: コピー","kk-KZ":"Терминал: Көшіру","nb-NO":"Terminal: Kopier","pl-PL":"Terminal: Kopiuj","pt-BR":"Terminal: Copiar","ru-RU":"Терминал: Копировать","tr-TR":"Terminal: Kopyala","uk-UA":"Термінал: Копіювати","vi-VN":"Terminal: Sao chép","zh-CN":"终端: 复制","zh-TW":"終端機: 複製"}},{"gesture":"Ctrl + Shift + V","category":"App","labels":{"bn":"টার্মিনাল: পেস্ট","cs-CZ":"Terminál: Vložit","de-DE":"Terminal: Einfügen","en-US":"Terminal: Paste","es":"Terminal: Pegar","fr-FR":"Terminal: Coller","hu-HU":"Terminál: Beillesztés","it-IT":"Terminale: Incolla","ja-JP":"ターミナル: 貼り付け","kk-KZ":"Терминал: Қою","nb-NO":"Terminal: Lim inn","pl-PL":"Terminal: Wklej","pt-BR":"Terminal: Colar","ru-RU":"Терминал: Вставить","tr-TR":"Terminal: Yapıştır","uk-UA":"Термінал: Вставити","vi-VN":"Terminal: Dán","zh-CN":"终端: 粘贴","zh-TW":"終端機: 貼上"}},{"gesture":"Ctrl + Shift + A","category":"App","labels":{"bn":"টার্মিনাল: সব নির্বাচন","cs-CZ":"Terminál: Vybrat vše","de-DE":"Terminal: Alles auswählen","en-US":"Terminal: Select all","es":"Terminal: Seleccionar todo","fr-FR":"Terminal: Tout sélectionner","hu-HU":"Terminál: Összes kijelölése","it-IT":"Terminale: Seleziona tutto","ja-JP":"ターミナル: すべて選択","kk-KZ":"Терминал: Барлығын таңдау","nb-NO":"Terminal: Merk alt","pl-PL":"Terminal: Zaznacz wszystko","pt-BR":"Terminal: Selecionar tudo","ru-RU":"Терминал: Выделить все","tr-TR":"Terminal: Tümünü seç","uk-UA":"Термінал: Виділити все","vi-VN":"Terminal: Chọn tất cả","zh-CN":"终端: 全选","zh-TW":"終端機: 全選"}},{"gesture":"Ctrl + Shift + Y","category":"App","labels":{"bn":"টার্মিনাল: সব কপি","cs-CZ":"Terminál: Kopírovat vše","de-DE":"Terminal: Alles kopieren","en-US":"Terminal: Copy All","es":"Terminal: Copiar todo","fr-FR":"Terminal: Tout copier","hu-HU":"Terminál: Összes másolása","it-IT":"Terminale: Copia tutto","ja-JP":"ターミナル: すべてコピー","kk-KZ":"Терминал: Барлығын көшіру","nb-NO":"Terminal: Kopier alt","pl-PL":"Terminal: Kopiuj wszystko","pt-BR":"Terminal: Copiar tudo","ru-RU":"Терминал: Копировать все","tr-TR":"Terminal: Tümünü kopyala","uk-UA":"Термінал: Копіювати все","vi-VN":"Terminal: Sao chép tất cả","zh-CN":"终端: 复制全部","zh-TW":"終端機: 複製全部"}},{"gesture":"Ctrl + Shift + L","category":"App","labels":{"bn":"টার্মিনাল: পরিষ্কার","cs-CZ":"Terminál: Vymazat","de-DE":"Terminal: Leeren","en-US":"Terminal: Clear","es":"Terminal: Limpiar","fr-FR":"Terminal: Effacer","hu-HU":"Terminál: Törlés","it-IT":"Terminale: Pulisci","ja-JP":"ターミナル: クリア","kk-KZ":"Терминал: Тазарту","nb-NO":"Terminal: Tøm","pl-PL":"Terminal: Wyczyść","pt-BR":"Terminal: Limpar","ru-RU":"Терминал: Очистить","tr-TR":"Terminal: Temizle","uk-UA":"Термінал: Очистити","vi-VN":"Terminal: Xóa","zh-CN":"终端: 清屏","zh-TW":"終端機: 清除"}},{"gesture":"Ctrl + G","category":"Topology","labels":{"bn":"টপোলজি বিন্যাস পরিবর্তন করুন","cs-CZ":"Procházet uspořádání topologie","de-DE":"Topologieanordnung wechseln","en-US":"Cycle topology arrangement","es":"Cambiar disposición de topología","fr-FR":"Changer la disposition de la topologie","hu-HU":"Topológiaelrendezés váltása","it-IT":"Cambia disposizione topologia","ja-JP":"トポロジの配置を切り替える","kk-KZ":"Топология орналасуын ауыстыру","nb-NO":"Bla gjennom topologiordninger","pl-PL":"Przełącz układ topologii","pt-BR":"Alternar a organização da topologia","ru-RU":"Переключить расположение топологии","tr-TR":"Topoloji düzenini değiştir","uk-UA":"Перемкнути впорядкування топології","vi-VN":"Chuyển kiểu sắp xếp sơ đồ","zh-CN":"切换拓扑排列方式","zh-TW":"切換拓撲排列方式"}},{"gesture":"Ctrl + 1","category":"Topology","labels":{"bn":"টপোলজি সাজান ভূমিকা","cs-CZ":"Uspořádat topologii podle Role","de-DE":"Topologie anordnen nach Rolle","en-US":"Arrange topology by Role","es":"Organizar topología por Función","fr-FR":"Organiser la topologie par Rôle","hu-HU":"Topológia rendezése Szerep","it-IT":"Disponi topologia per Ruolo","ja-JP":"トポロジの並べ替え 役割","kk-KZ":"Топологияны реттеу Рөл","nb-NO":"Ordne topologien etter Rolle","pl-PL":"Rozmieść topologię według Rola","pt-BR":"Organizar a topologia por Função","ru-RU":"Упорядочить топологию по Роль","tr-TR":"Topolojiyi şuna göre düzenle Rol","uk-UA":"Упорядкувати топологію за Роль","vi-VN":"Sắp xếp sơ đồ theo Vai trò","zh-CN":"拓扑排列方式 角色","zh-TW":"拓撲排列方式 角色"}},{"gesture":"Ctrl + 2","category":"Topology","labels":{"bn":"টপোলজি সাজান ধরন","cs-CZ":"Uspořádat topologii podle Typ","de-DE":"Topologie anordnen nach Typ","en-US":"Arrange topology by Type","es":"Organizar topología por Tipo","fr-FR":"Organiser la topologie par Type","hu-HU":"Topológia rendezése Írja be","it-IT":"Disponi topologia per Tipo","ja-JP":"トポロジの並べ替え 種類","kk-KZ":"Топологияны реттеу Түрі","nb-NO":"Ordne topologien etter Type","pl-PL":"Rozmieść topologię według Typ","pt-BR":"Organizar a topologia por Tipo","ru-RU":"Упорядочить топологию по Тип","tr-TR":"Topolojiyi şuna göre düzenle Tür","uk-UA":"Упорядкувати топологію за Тип","vi-VN":"Sắp xếp sơ đồ theo Loại","zh-CN":"拓扑排列方式 类型","zh-TW":"拓撲排列方式 類型"}},{"gesture":"Ctrl + 3","category":"Topology","labels":{"bn":"টপোলজি সাজান IP ঠিকানা","cs-CZ":"Uspořádat topologii podle IP adresa","de-DE":"Topologie anordnen nach IP-Adresse","en-US":"Arrange topology by IP Address","es":"Organizar topología por Dirección IP","fr-FR":"Organiser la topologie par Adresse IP","hu-HU":"Topológia rendezése IP-cím","it-IT":"Disponi topologia per Indirizzo IP","ja-JP":"トポロジの並べ替え IP アドレス","kk-KZ":"Топологияны реттеу IP мекенжайы","nb-NO":"Ordne topologien etter IP-adresse","pl-PL":"Rozmieść topologię według Adres IP","pt-BR":"Organizar a topologia por Endereço IP","ru-RU":"Упорядочить топологию по IP-адрес","tr-TR":"Topolojiyi şuna göre düzenle IP Adresi","uk-UA":"Упорядкувати топологію за IP-адреса","vi-VN":"Sắp xếp sơ đồ theo Địa chỉ IP","zh-CN":"拓扑排列方式 IP 地址","zh-TW":"拓撲排列方式 IP 位址"}},{"gesture":"Ctrl + 4","category":"Topology","labels":{"bn":"টপোলজি সাজান প্রস্তুতকারক","cs-CZ":"Uspořádat topologii podle Výrobce","de-DE":"Topologie anordnen nach Hersteller","en-US":"Arrange topology by Vendor","es":"Organizar topología por Fabricante","fr-FR":"Organiser la topologie par Fabricant","hu-HU":"Topológia rendezése Eladó","it-IT":"Disponi topologia per Produttore","ja-JP":"トポロジの並べ替え ベンダー","kk-KZ":"Топологияны реттеу Өндіруші","nb-NO":"Ordne topologien etter Produsent","pl-PL":"Rozmieść topologię według Producent","pt-BR":"Organizar a topologia por Fabricante","ru-RU":"Упорядочить топологию по Производитель","tr-TR":"Topolojiyi şuna göre düzenle Üretici","uk-UA":"Упорядкувати топологію за Виробник","vi-VN":"Sắp xếp sơ đồ theo Nhà sản xuất","zh-CN":"拓扑排列方式 厂商","zh-TW":"拓撲排列方式 廠商"}},{"gesture":"Ctrl + Shift + 1","category":"Toolbar","labels":{"bn":"ছোট আইকন","cs-CZ":"Malé ikony","de-DE":"Kleine Symbole","en-US":"Small icons","es":"Iconos pequeños","fr-FR":"Petites icônes","hu-HU":"Kis ikonok","it-IT":"Icone piccole","ja-JP":"小さいアイコン","kk-KZ":"Кішкентай белгішелер","nb-NO":"Små ikoner","pl-PL":"Małe ikony","pt-BR":"Ícones pequenos","ru-RU":"Маленькие иконки","tr-TR":"Küçük simgeler","uk-UA":"Малі значки","vi-VN":"Biểu tượng nhỏ","zh-CN":"小图标","zh-TW":"小圖示"}},{"gesture":"Ctrl + Shift + 2","category":"Toolbar","labels":{"bn":"বড় আইকন","cs-CZ":"Velké ikony","de-DE":"Große Symbole","en-US":"Large icons","es":"Iconos grandes","fr-FR":"Grandes icônes","hu-HU":"Nagy ikonok","it-IT":"Icone grandi","ja-JP":"大きいアイコン","kk-KZ":"Үлкен белгішелер","nb-NO":"Store ikoner","pl-PL":"Duże ikony","pt-BR":"Ícones grandes","ru-RU":"Большие иконки","tr-TR":"Büyük simgeler","uk-UA":"Великі значки","vi-VN":"Biểu tượng lớn","zh-CN":"大图标","zh-TW":"大圖示"}},{"gesture":"Ctrl + Shift + 3","category":"Toolbar","labels":{"bn":"লেখা নেই","cs-CZ":"Bez textu","de-DE":"Kein Text","en-US":"No text","es":"Sin texto","fr-FR":"Sans texte","hu-HU":"Nincs szöveg","it-IT":"Nessun testo","ja-JP":"文字なし","kk-KZ":"Мәтін жоқ","nb-NO":"Ingen tekst","pl-PL":"Brak tekstu","pt-BR":"Sem texto","ru-RU":"Нет текста","tr-TR":"Metin yok","uk-UA":"Без тексту","vi-VN":"Không có văn bản","zh-CN":"无文字","zh-TW":"無文字"}},{"gesture":"Ctrl + Shift + 4","category":"Toolbar","labels":{"bn":"আইকনের পাশে লেখা","cs-CZ":"Text vedle ikon","de-DE":"Text neben Symbol","en-US":"Text beside icons","es":"Texto junto a iconos","fr-FR":"Texte à côté des icônes","hu-HU":"Szöveg az ikonok mellett","it-IT":"Testo accanto alle icone","ja-JP":"アイコンの横にテキスト","kk-KZ":"Мәтін белгішенің қасында","nb-NO":"Tekst ved siden av ikoner","pl-PL":"Tekst obok ikon","pt-BR":"Texto ao lado dos ícones","ru-RU":"Текст рядом с иконками","tr-TR":"Simge yanında metin","uk-UA":"Текст поруч зі значками","vi-VN":"Văn bản cạnh biểu tượng","zh-CN":"图标加文本","zh-TW":"圖示加文字"}},{"gesture":"Ctrl + Shift + 5","category":"Toolbar","labels":{"bn":"আইকনের নিচে লেখা","cs-CZ":"Text pod ikonami","de-DE":"Text unter Symbol","en-US":"Text under icons","es":"Texto bajo los iconos","fr-FR":"Texte sous les icônes","hu-HU":"Szöveg az ikonok alatt","it-IT":"Testo sotto le icone","ja-JP":"アイコンの下にテキスト","kk-KZ":"Мәтін белгішенің астында","nb-NO":"Tekst under ikoner","pl-PL":"Tekst pod ikonami","pt-BR":"Texto abaixo dos ícones","ru-RU":"Текст под иконками","tr-TR":"Simge altında metin","uk-UA":"Текст під значками","vi-VN":"Văn bản dưới biểu tượng","zh-CN":"图标下方文本","zh-TW":"圖示下方文字"}},{"gesture":"Ctrl + Shift + 6","category":"Toolbar","labels":{"bn":"শুধু লেখা","cs-CZ":"Pouze text","de-DE":"Nur Text","en-US":"Text only","es":"Solo texto","fr-FR":"Texte seul","hu-HU":"Csak szöveg","it-IT":"Solo testo","ja-JP":"テキストのみ","kk-KZ":"Тек мәтін","nb-NO":"Bare tekst","pl-PL":"Tylko tekst","pt-BR":"Somente texto","ru-RU":"Только текст","tr-TR":"Yalnızca metin","uk-UA":"Лише текст","vi-VN":"Chỉ văn bản","zh-CN":"仅文本","zh-TW":"僅文字"}},{"gesture":"Ctrl + Shift + +","category":"App","labels":{"bn":"অ্যাপ বড় করুন","cs-CZ":"Zvětšit aplikaci","de-DE":"App vergrößern","en-US":"Make the app bigger","es":"Agrandar la aplicación","fr-FR":"Agrandir l'application","hu-HU":"Tedd nagyobbá az alkalmazást","it-IT":"Ingrandisci l'app","ja-JP":"アプリを大きくする","kk-KZ":"Қолданбаны үлкейту","nb-NO":"Gjør appen større","pl-PL":"Powiększ aplikację","pt-BR":"Aumentar o app","ru-RU":"Увеличить приложение","tr-TR":"Uygulamayı büyüt","uk-UA":"Збільшити застосунок","vi-VN":"Phóng to ứng dụng","zh-CN":"放大应用","zh-TW":"放大應用程式"}},{"gesture":"Ctrl + Shift + -","category":"App","labels":{"bn":"অ্যাপ ছোট করুন","cs-CZ":"Zmenšit aplikaci","de-DE":"App verkleinern","en-US":"Make the app smaller","es":"Reducir la aplicación","fr-FR":"Réduire l'application","hu-HU":"Csökkentse az alkalmazást","it-IT":"Rimpicciolisci l'app","ja-JP":"アプリを小さくする","kk-KZ":"Қолданбаны кішірейту","nb-NO":"Gjør appen mindre","pl-PL":"Pomniejsz aplikację","pt-BR":"Diminuir o app","ru-RU":"Уменьшить приложение","tr-TR":"Uygulamayı küçült","uk-UA":"Зменшити застосунок","vi-VN":"Thu nhỏ ứng dụng","zh-CN":"缩小应用","zh-TW":"縮小應用程式"}},{"gesture":"Ctrl + Shift + 0","category":"App","labels":{"bn":"অ্যাপের আকার রিসেট করুন","cs-CZ":"Obnovit výchozí velikost aplikace","de-DE":"App-Größe zurücksetzen","en-US":"Reset the app size","es":"Restablecer el tamaño de la aplicación","fr-FR":"Réinitialiser la taille de l'application","hu-HU":"Állítsa vissza az alkalmazás méretét","it-IT":"Ripristina la dimensione dell'app","ja-JP":"アプリのサイズをリセット","kk-KZ":"Қолданба өлшемін қалпына келтіру","nb-NO":"Tilbakestill appstørrelsen","pl-PL":"Przywróć domyślny rozmiar","pt-BR":"Redefinir o tamanho do app","ru-RU":"Сбросить размер приложения","tr-TR":"Uygulama boyutunu sıfırla","uk-UA":"Скинути розмір застосунку","vi-VN":"Đặt lại kích thước ứng dụng","zh-CN":"重置应用大小","zh-TW":"重設應用程式大小"}},{"gesture":"Alt + Shift + G","category":"App","labels":{"bn":"ফিল্ম গ্রেইন","cs-CZ":"Filmové zrno","de-DE":"Filmkorn","en-US":"Film Grain","es":"Grano de película","fr-FR":"Grain de film","hu-HU":"Filmszemcse","it-IT":"Grana pellicola","ja-JP":"フィルムグレイン","kk-KZ":"Пленка түйіршігі","nb-NO":"Filmkorn","pl-PL":"Ziarno filmowe","pt-BR":"Granulação de filme","ru-RU":"Зернистость плёнки","tr-TR":"Film greni","uk-UA":"Зернистість плівки","vi-VN":"Hạt phim","zh-CN":"胶片颗粒","zh-TW":"底片顆粒"}},{"gesture":"F1","category":"App","labels":{"bn":"কীবোর্ড শর্টকাট দেখান বা লুকান","cs-CZ":"Zobrazit nebo skrýt klávesové zkratky","de-DE":"Tastenkürzel ein- oder ausblenden","en-US":"Show or hide keyboard shortcuts","es":"Mostrar u ocultar los atajos de teclado","fr-FR":"Afficher ou masquer les raccourcis clavier","hu-HU":"Billentyűparancsok megjelenítése vagy elrejtése","it-IT":"Mostra o nascondi le scorciatoie da tastiera","ja-JP":"キーボードショートカットを表示または非表示","kk-KZ":"Перне тіркесімдерін көрсету немесе жасыру","nb-NO":"Vis eller skjul hurtigtaster","pl-PL":"Pokaż lub ukryj skróty klawiszowe","pt-BR":"Mostrar ou ocultar os atalhos de teclado","ru-RU":"Показать или скрыть сочетания клавиш","tr-TR":"Klavye kısayollarını göster veya gizle","uk-UA":"Показати або сховати сполучення клавіш","vi-VN":"Hiện hoặc ẩn phím tắt","zh-CN":"显示或隐藏键盘快捷键","zh-TW":"顯示或隱藏鍵盤快捷鍵"}},{"gesture":"F12","category":"App","labels":{"bn":"সম্পর্কে উইন্ডোটি খুলুন","cs-CZ":"Otevřít okno O aplikaci","de-DE":"Das Info-Fenster öffnen","en-US":"Open the About window","es":"Abrir la ventana Acerca de","fr-FR":"Ouvrir la fenêtre À propos","hu-HU":"Nyissa meg a névjegy ablakát","it-IT":"Apri la finestra Informazioni","ja-JP":"バージョン情報ウィンドウを開く","kk-KZ":"Қолданба туралы терезесін ашу","nb-NO":"Åpne Om-vinduet","pl-PL":"Otwórz okno O programie","pt-BR":"Abrir a janela Sobre","ru-RU":"Открыть окно «О программе»","tr-TR":"Hakkında penceresini aç","uk-UA":"Відкрити вікно «Про програму»","vi-VN":"Mở cửa sổ Giới thiệu","zh-CN":"打开关于窗口","zh-TW":"開啟關於視窗"}}]; +window.KS_SHORTCUTS = [{"gesture":"F6","category":"Views","labels":{"bn":"ডিভাইস","cs-CZ":"Zařízení","de-DE":"Geräte","en-US":"Devices","es":"Dispositivos","fr-FR":"Appareils","hu-HU":"Eszközök","it-IT":"Dispositivi","ja-JP":"デバイス","kk-KZ":"Құрылғылар","nb-NO":"Enheter","pl-PL":"Urządzenia","pt-BR":"Dispositivos","ru-RU":"Устройства","sl-SI":"Naprave","tr-TR":"Cihazlar","uk-UA":"Пристрої","vi-VN":"Thiết bị","zh-CN":"设备","zh-TW":"裝置"}},{"gesture":"F7","category":"Views","labels":{"bn":"সেবাসমূহ","cs-CZ":"Služby","de-DE":"Dienste","en-US":"Services","es":"Servicios","fr-FR":"Services","hu-HU":"Szolgáltatások","it-IT":"Servizi","ja-JP":"サービス","kk-KZ":"Қызметтер","nb-NO":"Tjenester","pl-PL":"Usługi","pt-BR":"Serviços","ru-RU":"Службы","sl-SI":"Storitve","tr-TR":"Hizmetler","uk-UA":"Служби","vi-VN":"Dịch vụ","zh-CN":"服务","zh-TW":"服務"}},{"gesture":"F8","category":"Views","labels":{"bn":"নেটওয়ার্ক টপোলজি দেখান বা লুকান","cs-CZ":"Přepnout topologii sítě","de-DE":"Netzwerktopologie umschalten","en-US":"Toggle network topology","es":"Alternar topología de red","fr-FR":"Afficher ou masquer la topologie du réseau","hu-HU":"Hálózati topológia megjelenítése","it-IT":"Mostra o nascondi la topologia di rete","ja-JP":"ネットワーク トポロジの表示を切り替えます","kk-KZ":"Желі топологиясын көрсету немесе жасыру","nb-NO":"Slå nettverkstopologi av eller på","pl-PL":"Przełącz topologię sieci","pt-BR":"Alternar topologia da rede","ru-RU":"Показать или скрыть топологию сети","sl-SI":"Prikaži ali skrij topologijo omrežja","tr-TR":"Ağ topolojisini aç veya kapat","uk-UA":"Показати або сховати топологію мережі","vi-VN":"Bật hoặc tắt sơ đồ mạng","zh-CN":"切换网络拓扑","zh-TW":"切換網路拓撲"}},{"gesture":"F9","category":"Views","labels":{"bn":"সংযোগ পর্যবেক্ষণ","cs-CZ":"Sledování připojení","de-DE":"Verbindungsüberwachung","en-US":"Keep Alive","es":"Supervisión de conexión","fr-FR":"Surveillance de connexion","hu-HU":"Kapcsolatfigyelés","it-IT":"Monitoraggio connessione","ja-JP":"接続監視","kk-KZ":"Қосылымды бақылау","nb-NO":"Tilkoblingsovervåking","pl-PL":"Monitorowanie połączenia","pt-BR":"Monitoramento de conexão","ru-RU":"Наблюдение за соединением","sl-SI":"Nadzor povezave","tr-TR":"Bağlantı izleme","uk-UA":"Спостереження за з'єднанням","vi-VN":"Giám sát kết nối","zh-CN":"连接监控","zh-TW":"連線監控"}},{"gesture":"F10","category":"Views","labels":{"bn":"টার্মিনাল","cs-CZ":"Terminál","de-DE":"Terminal","en-US":"Terminal","es":"Terminal","fr-FR":"Terminal","hu-HU":"Terminál","it-IT":"Terminale","ja-JP":"ターミナル","kk-KZ":"Терминал","nb-NO":"Terminal","pl-PL":"Terminal","pt-BR":"Terminal","ru-RU":"Терминал","sl-SI":"Terminal","tr-TR":"Terminal","uk-UA":"Термінал","vi-VN":"Terminal","zh-CN":"终端","zh-TW":"終端機"}},{"gesture":"Ctrl + T","category":"Views","labels":{"bn":"ডিভাইস দেখান","cs-CZ":"Zobrazit zařízení","de-DE":"Geräte anzeigen","en-US":"Show Devices","es":"Mostrar dispositivos","fr-FR":"Afficher les appareils","hu-HU":"Eszközök megjelenítése","it-IT":"Mostra dispositivi","ja-JP":"デバイスを表示","kk-KZ":"Құрылғыларды көрсету","nb-NO":"Vis Enheter","pl-PL":"Pokaż urządzenia","pt-BR":"Mostrar Dispositivos","ru-RU":"Показать устройства","sl-SI":"Prikaži naprave","tr-TR":"Cihazları göster","uk-UA":"Показати пристрої","vi-VN":"Hiện Thiết bị","zh-CN":"显示设备","zh-TW":"顯示裝置"}},{"gesture":"Ctrl + Shift + T","category":"Views","labels":{"bn":"টার্মিনাল দেখান","cs-CZ":"Zobrazit terminál","de-DE":"Terminal anzeigen","en-US":"Show Terminal","es":"Mostrar terminal","fr-FR":"Afficher le terminal","hu-HU":"Terminál megjelenítése","it-IT":"Mostra terminale","ja-JP":"ターミナルを表示","kk-KZ":"Терминалды көрсету","nb-NO":"Vis Terminal","pl-PL":"Pokaż terminal","pt-BR":"Mostrar Terminal","ru-RU":"Показать терминал","sl-SI":"Prikaži terminal","tr-TR":"Terminali göster","uk-UA":"Показати термінал","vi-VN":"Hiện Terminal","zh-CN":"显示终端","zh-TW":"顯示終端機"}},{"gesture":"Ctrl + H","category":"Views","labels":{"bn":"স্ক্যান ইতিহাস","cs-CZ":"Historie skenování","de-DE":"Scanverlauf","en-US":"Scan History","es":"Historial de análisis","fr-FR":"Historique des analyses","hu-HU":"Vizsgálati előzmények","it-IT":"Cronologia scansioni","ja-JP":"スキャン履歴","kk-KZ":"Сканерлеу тарихы","nb-NO":"Skanneloggen","pl-PL":"Historia skanowania","pt-BR":"Histórico de verificações","ru-RU":"История сканирования","sl-SI":"Zgodovina pregledov","tr-TR":"Tarama geçmişi","uk-UA":"Історія сканувань","vi-VN":"Lịch sử quét","zh-CN":"扫描历史","zh-TW":"掃描歷史"}},{"gesture":"Ctrl + Alt + R","category":"Views","labels":{"bn":"ইতিহাসের সেটিংস","cs-CZ":"Nastavení historie","de-DE":"Verlaufseinstellungen","en-US":"History settings","es":"Configuración del historial","fr-FR":"Paramètres de l'historique","hu-HU":"Előzmények beállításai","it-IT":"Impostazioni cronologia","ja-JP":"履歴設定","kk-KZ":"Тарих параметрлері","nb-NO":"Historikkinnstillinger","pl-PL":"Ustawienia historii","pt-BR":"Configurações do histórico","ru-RU":"Настройки истории","sl-SI":"Nastavitve zgodovine","tr-TR":"Geçmiş ayarları","uk-UA":"Налаштування історії","vi-VN":"Cài đặt lịch sử","zh-CN":"历史设置","zh-TW":"歷史設定"}},{"gesture":"Ctrl + Shift + P","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল","cs-CZ":"Profily skenování","de-DE":"Scanprofile","en-US":"Scan Profiles","es":"Perfiles de análisis","fr-FR":"Profils d’analyse","hu-HU":"Vizsgálati profilok","it-IT":"Profili di scansione","ja-JP":"スキャンプロファイル","kk-KZ":"Сканерлеу профильдері","nb-NO":"Skanneprofiler","pl-PL":"Profile skanowania","pt-BR":"Perfis de verificação","ru-RU":"Профили сканирования","sl-SI":"Profili pregledov","tr-TR":"Tarama profilleri","uk-UA":"Профілі сканування","vi-VN":"Hồ sơ quét","zh-CN":"扫描配置","zh-TW":"掃描設定檔"}},{"gesture":"F4","category":"Views","labels":{"bn":"KillerSpeed","cs-CZ":"KillerSpeed","de-DE":"KillerSpeed","en-US":"KillerSpeed","es":"KillerSpeed","fr-FR":"KillerSpeed","hu-HU":"KillerSpeed","it-IT":"KillerSpeed","ja-JP":"KillerSpeed","kk-KZ":"KillerSpeed","nb-NO":"KillerSpeed","pl-PL":"KillerSpeed","pt-BR":"KillerSpeed","ru-RU":"KillerSpeed","sl-SI":"KillerSpeed","tr-TR":"KillerSpeed","uk-UA":"KillerSpeed","vi-VN":"KillerSpeed","zh-CN":"KillerSpeed","zh-TW":"KillerSpeed"}},{"gesture":"F5","category":"Scan","labels":{"bn":"স্ক্যান শুরু বা বন্ধ করুন","cs-CZ":"Spustit nebo zastavit skenování","de-DE":"Scan starten oder stoppen","en-US":"Start or stop a scan","es":"Iniciar o detener un escaneo","fr-FR":"Démarrer ou arrêter une analyse","hu-HU":"Indítsa el vagy állítsa le a keresést","it-IT":"Avvia o interrompi una scansione","ja-JP":"スキャンの開始または停止","kk-KZ":"Сканерлеуді бастау немесе тоқтату","nb-NO":"Start eller stopp en skanning","pl-PL":"Rozpocznij lub zatrzymaj skanowanie","pt-BR":"Iniciar ou parar uma verificação","ru-RU":"Запустить или остановить сканирование","sl-SI":"Začni ali ustavi pregled","tr-TR":"Taramayı başlat veya durdur","uk-UA":"Почати або зупинити сканування","vi-VN":"Bắt đầu hoặc dừng quét","zh-CN":"开始或停止扫描","zh-TW":"開始或停止掃描"}},{"gesture":"Esc","category":"Scan","labels":{"bn":"স্ক্যান বাতিল করুন, বা এটি বন্ধ করুন","cs-CZ":"Zrušit skenování nebo zavřít toto okno","de-DE":"Scan abbrechen oder dies schließen","en-US":"Cancel the scan, or close this","es":"Cancelar el escaneo o cerrar esto","fr-FR":"Annuler l'analyse, ou fermer ceci","hu-HU":"Szakítsa meg a beolvasást, vagy zárja be","it-IT":"Annulla la scansione, o chiudi questa finestra","ja-JP":"スキャンの取り消し、またはこれを閉じる","kk-KZ":"Сканерлеуді болдырмау немесе осыны жабу","nb-NO":"Avbryt skanningen, eller lukk dette","pl-PL":"Anuluj skanowanie lub zamknij to okno","pt-BR":"Cancelar a verificação ou fechar isto","ru-RU":"Отменить сканирование или закрыть это окно","sl-SI":"Prekliči pregled ali zapri ta prikaz","tr-TR":"Taramayı iptal et veya bunu kapat","uk-UA":"Скасувати сканування або закрити це вікно","vi-VN":"Hủy quét hoặc đóng cửa sổ này","zh-CN":"取消扫描,或关闭此窗口","zh-TW":"取消掃描,或關閉此視窗"}},{"gesture":"Ctrl + R","category":"Scan","labels":{"bn":"নির্বাচিত হোস্ট গভীরভাবে পুনরায় স্ক্যান করুন","cs-CZ":"Hloubkově znovu skenovat vybrané hostitele","de-DE":"Ausgewählte Hosts tief erneut scannen","en-US":"Deep rescan the selected hosts","es":"Reescaneo profundo de los hosts seleccionados","fr-FR":"Réanalyser en profondeur les hôtes sélectionnés","hu-HU":"A kiválasztott gazdagépek mélyreható újrakeresése","it-IT":"Scansione approfondita degli host selezionati","ja-JP":"選択したホストを詳細に再スキャン","kk-KZ":"Таңдалған хосттарды терең қайта сканерлеу","nb-NO":"Dypskann de valgte vertene på nytt","pl-PL":"Dokładne ponowne skanowanie wybranych hostów","pt-BR":"Refazer a verificação profunda dos hosts selecionados","ru-RU":"Глубокое пересканирование выбранных хостов","sl-SI":"Podrobno ponovno preglej izbrane gostitelje","tr-TR":"Seçili ana bilgisayarları derin yeniden tara","uk-UA":"Глибоко пересканувати вибрані хости","vi-VN":"Quét sâu lại các máy chủ đã chọn","zh-CN":"深度重新扫描所选主机","zh-TW":"深度重新掃描所選主機"}},{"gesture":"Ctrl + F","category":"Scan","labels":{"bn":"সাবনেট বাক্সে যান","cs-CZ":"Přejít do pole podsítě","de-DE":"Zum Subnetz-Feld springen","en-US":"Jump to the subnet box","es":"Ir al cuadro de subred","fr-FR":"Aller au champ de sous-réseau","hu-HU":"Ugrás az alhálózati mezőre","it-IT":"Vai alla casella della sottorete","ja-JP":"サブネット入力欄へ移動","kk-KZ":"Ішкі желі өрісіне өту","nb-NO":"Gå til subnettfeltet","pl-PL":"Przejdź do pola podsieci","pt-BR":"Ir para a caixa de sub-rede","ru-RU":"Перейти к полю подсети","sl-SI":"Pojdi v polje podomrežja","tr-TR":"Alt ağ kutusuna git","uk-UA":"Перейти до поля підмережі","vi-VN":"Chuyển đến ô mạng con","zh-CN":"跳转到子网输入框","zh-TW":"跳至子網路輸入框"}},{"gesture":"Ctrl + Shift + F","category":"Scan","labels":{"bn":"ডিভাইস ফিল্টার করুন...","cs-CZ":"Filtrovat zařízení...","de-DE":"Geräte filtern...","en-US":"Filter devices...","es":"Filtrar dispositivos...","fr-FR":"Filtrer les appareils...","hu-HU":"Eszközök szűrése...","it-IT":"Filtra dispositivi...","ja-JP":"デバイスを絞り込み...","kk-KZ":"Құрылғыларды сүзу...","nb-NO":"Filtrer enheter...","pl-PL":"Filtruj urządzenia...","pt-BR":"Filtrar dispositivos...","ru-RU":"Фильтр устройств...","sl-SI":"Filtriraj naprave...","tr-TR":"Cihazları filtrele...","uk-UA":"Фільтр пристроїв...","vi-VN":"Lọc thiết bị...","zh-CN":"筛选设备...","zh-TW":"篩選裝置..."}},{"gesture":"Ctrl + A","category":"Scan","labels":{"bn":"সব ডিভাইস নির্বাচন করুন","cs-CZ":"Vybrat všechna zařízení","de-DE":"Alle Geräte auswählen","en-US":"Select every device","es":"Seleccionar todos los dispositivos","fr-FR":"Sélectionner tous les appareils","hu-HU":"Válassza ki az összes eszközt","it-IT":"Seleziona tutti i dispositivi","ja-JP":"すべてのデバイスを選択","kk-KZ":"Барлық құрылғыны таңдау","nb-NO":"Merk alle enheter","pl-PL":"Zaznacz wszystkie urządzenia","pt-BR":"Selecionar todos os dispositivos","ru-RU":"Выбрать все устройства","sl-SI":"Izberi vse naprave","tr-TR":"Tüm cihazları seç","uk-UA":"Вибрати всі пристрої","vi-VN":"Chọn tất cả thiết bị","zh-CN":"选择所有设备","zh-TW":"選取所有裝置"}},{"gesture":"Ctrl + E","category":"Scan","labels":{"bn":"CSV হিসেবে এক্সপোর্ট","cs-CZ":"Exportovat jako CSV","de-DE":"Als CSV exportieren","en-US":"Export as CSV","es":"Exportar como CSV","fr-FR":"Exporter en CSV","hu-HU":"Exportálás CSV-ként","it-IT":"Esporta come CSV","ja-JP":"CSV としてエクスポート","kk-KZ":"CSV ретінде экспорттау","nb-NO":"Eksporter som CSV","pl-PL":"Eksportuj jako CSV","pt-BR":"Exportar como CSV","ru-RU":"Экспорт в CSV","sl-SI":"Izvozi v CSV","tr-TR":"CSV olarak dışa aktar","uk-UA":"Експортувати як CSV","vi-VN":"Xuất dạng CSV","zh-CN":"导出为 CSV","zh-TW":"匯出為 CSV"}},{"gesture":"Ctrl + Shift + E","category":"Scan","labels":{"bn":"HTML হিসেবে এক্সপোর্ট","cs-CZ":"Exportovat jako HTML","de-DE":"Als HTML exportieren","en-US":"Export as HTML","es":"Exportar como HTML","fr-FR":"Exporter en HTML","hu-HU":"Exportálás HTML-ként","it-IT":"Esporta come HTML","ja-JP":"HTML としてエクスポート","kk-KZ":"HTML ретінде экспорттау","nb-NO":"Eksporter som HTML","pl-PL":"Eksportuj jako HTML","pt-BR":"Exportar como HTML","ru-RU":"Экспорт в HTML","sl-SI":"Izvozi v HTML","tr-TR":"HTML olarak dışa aktar","uk-UA":"Експортувати як HTML","vi-VN":"Xuất dạng HTML","zh-CN":"导出为 HTML","zh-TW":"匯出為 HTML"}},{"gesture":"Ctrl + Shift + X","category":"Scan","labels":{"bn":"TXT হিসেবে এক্সপোর্ট","cs-CZ":"Exportovat jako TXT","de-DE":"Als TXT exportieren","en-US":"Export as TXT","es":"Exportar como TXT","fr-FR":"Exporter en TXT","hu-HU":"Exportálás TXT-ként","it-IT":"Esporta come TXT","ja-JP":"TXT としてエクスポート","kk-KZ":"TXT ретінде экспорттау","nb-NO":"Eksporter som TXT","pl-PL":"Eksportuj jako TXT","pt-BR":"Exportar como TXT","ru-RU":"Экспорт в TXT","sl-SI":"Izvozi v TXT","tr-TR":"TXT olarak dışa aktar","uk-UA":"Експортувати як TXT","vi-VN":"Xuất dạng TXT","zh-CN":"导出为 TXT","zh-TW":"匯出為 TXT"}},{"gesture":"Ctrl + Shift + G","category":"Scan","labels":{"bn":"কার্ড PNG হিসেবে এক্সপোর্ট","cs-CZ":"Exportovat karty jako PNG","de-DE":"Karten als PNG exportieren","en-US":"Export cards as PNG","es":"Exportar tarjetas como PNG","fr-FR":"Exporter les cartes en PNG","hu-HU":"Kártyák exportálása PNG-ként","it-IT":"Esporta le schede come PNG","ja-JP":"カードを PNG にエクスポート","kk-KZ":"Карталарды PNG ретінде экспорттау","nb-NO":"Eksporter kort som PNG","pl-PL":"Eksportuj karty jako PNG","pt-BR":"Exportar cartões como PNG","ru-RU":"Экспорт карточек в PNG","sl-SI":"Izvozi kartice v PNG","tr-TR":"Kartları PNG olarak dışa aktar","uk-UA":"Експортувати картки як PNG","vi-VN":"Xuất thẻ dạng PNG","zh-CN":"将卡片导出为 PNG","zh-TW":"將卡片匯出為 PNG"}},{"gesture":"Ctrl + Shift + J","category":"Scan","labels":{"bn":"JPG-তে এক্সপোর্ট","cs-CZ":"Exportovat do JPG","de-DE":"Als JPG exportieren","en-US":"Export to JPG","es":"Exportar a JPG","fr-FR":"Exporter en JPG","hu-HU":"Exportálás JPG-be","it-IT":"Esporta in JPG","ja-JP":"JPG にエクスポート","kk-KZ":"JPG форматына экспорттау","nb-NO":"Eksporter til JPG","pl-PL":"Eksportuj do JPG","pt-BR":"Exportar para JPG","ru-RU":"Экспорт в JPG","sl-SI":"Izvozi v JPG","tr-TR":"JPG olarak dışa aktar","uk-UA":"Експортувати як JPG","vi-VN":"Xuất sang JPG","zh-CN":"导出为 JPG","zh-TW":"匯出為 JPG"}},{"gesture":"Ctrl + Alt + H","category":"Scan","labels":{"bn":"HTML-এ এক্সপোর্ট","cs-CZ":"Exportovat do HTML","de-DE":"Als HTML exportieren","en-US":"Export to HTML","es":"Exportar a HTML","fr-FR":"Exporter en HTML","hu-HU":"Exportálás HTML-be","it-IT":"Esporta in HTML","ja-JP":"HTML にエクスポート","kk-KZ":"HTML форматына экспорттау","nb-NO":"Eksporter til HTML","pl-PL":"Eksportuj do HTML","pt-BR":"Exportar para HTML","ru-RU":"Экспорт в HTML","sl-SI":"Izvozi v HTML","tr-TR":"HTML olarak dışa aktar","uk-UA":"Експортувати як HTML","vi-VN":"Xuất sang HTML","zh-CN":"导出为 HTML","zh-TW":"匯出為 HTML"}},{"gesture":"Enter","category":"Device","labels":{"bn":"নির্বাচিত ডিভাইস ব্রাউজারে খুলুন","cs-CZ":"Otevřít vybrané zařízení v prohlížeči","de-DE":"Ausgewähltes Gerät im Browser öffnen","en-US":"Open the selected device in a browser","es":"Abrir el dispositivo seleccionado en el navegador","fr-FR":"Ouvrir l'appareil sélectionné dans le navigateur","hu-HU":"Nyissa meg a kiválasztott eszközt egy böngészőben","it-IT":"Apri il dispositivo selezionato in un browser","ja-JP":"選択したデバイスをブラウザーで開く","kk-KZ":"Таңдалған құрылғыны браузерде ашу","nb-NO":"Åpne den valgte enheten i en nettleser","pl-PL":"Otwórz wybrane urządzenie w przeglądarce","pt-BR":"Abrir o dispositivo selecionado em um navegador","ru-RU":"Открыть выбранное устройство в браузере","sl-SI":"Odpri izbrano napravo v brskalniku","tr-TR":"Seçili cihazı tarayıcıda aç","uk-UA":"Відкрити вибраний пристрій у браузері","vi-VN":"Mở thiết bị đã chọn trong trình duyệt","zh-CN":"在浏览器中打开所选设备","zh-TW":"在瀏覽器中開啟所選裝置"}},{"gesture":"F3","category":"Device","labels":{"bn":"নির্বাচিত ডিভাইস Keep Alive-এ দেখান","cs-CZ":"Zobrazit vybrané zařízení v Keep Alive","de-DE":"Ausgewähltes Gerät in Keep Alive anzeigen","en-US":"Show selected device in Keep Alive","es":"Mostrar el dispositivo seleccionado en Keep Alive","fr-FR":"Afficher l'appareil sélectionné dans Keep Alive","hu-HU":"Kijelölt eszköz megjelenítése a Keep Alive nézetben","it-IT":"Mostra il dispositivo selezionato in Keep Alive","ja-JP":"選択デバイスを Keep Alive に表示","kk-KZ":"Таңдалған құрылғыны Keep Alive ішінде көрсету","nb-NO":"Vis valgt enhet i Keep Alive","pl-PL":"Pokaż wybrane urządzenie w Keep Alive","pt-BR":"Mostrar o dispositivo selecionado em Keep Alive","ru-RU":"Показать выбранное устройство в Keep Alive","sl-SI":"Prikaži izbrano napravo v nadzoru povezave","tr-TR":"Seçili cihazı Keep Alive'da göster","uk-UA":"Показати вибраний пристрій у Keep Alive","vi-VN":"Hiện thiết bị đã chọn trong Keep Alive","zh-CN":"在 Keep Alive 中显示选中设备","zh-TW":"在 Keep Alive 中顯示選取裝置"}},{"gesture":"Ctrl + P","category":"Device","labels":{"bn":"নির্বাচিত ডিভাইসে পিং করুন","cs-CZ":"Odeslat ping vybranému zařízení","de-DE":"Ausgewähltes Gerät anpingen","en-US":"Ping the selected device","es":"Hacer ping al dispositivo seleccionado","fr-FR":"Envoyer un ping à l'appareil sélectionné","hu-HU":"Pingelje a kiválasztott eszközt","it-IT":"Esegui il ping del dispositivo selezionato","ja-JP":"選択したデバイスに ping を送信","kk-KZ":"Таңдалған құрылғыға Ping жіберу","nb-NO":"Ping den valgte enheten","pl-PL":"Wyślij ping do wybranego urządzenia","pt-BR":"Fazer ping no dispositivo selecionado","ru-RU":"Выполнить ping выбранного устройства","sl-SI":"Preveri izbrano napravo s pingom","tr-TR":"Seçili cihaza ping gönder","uk-UA":"Пінгувати вибраний пристрій","vi-VN":"Ping thiết bị đã chọn","zh-CN":"Ping 所选设备","zh-TW":"Ping 所選裝置"}},{"gesture":"Ctrl + D","category":"Device","labels":{"bn":"নির্বাচিত ডিভাইসে রিমোট ডেস্কটপ খুলুন","cs-CZ":"Otevřít Vzdálenou plochu k vybranému zařízení","de-DE":"Remotedesktop zum ausgewählten Gerät öffnen","en-US":"Open Remote Desktop to the selected device","es":"Abrir Escritorio remoto en el dispositivo seleccionado","fr-FR":"Ouvrir le Bureau à distance vers l'appareil sélectionné","hu-HU":"Nyissa meg a Távoli asztalt a kiválasztott eszközön","it-IT":"Apri Desktop remoto verso il dispositivo selezionato","ja-JP":"選択したデバイスにリモート デスクトップで接続","kk-KZ":"Таңдалған құрылғыға қашықтағы жұмыс үстелін ашу","nb-NO":"Åpne Eksternt skrivebord til den valgte enheten","pl-PL":"Otwórz Pulpit zdalny do wybranego urządzenia","pt-BR":"Abrir a Área de Trabalho Remota no dispositivo selecionado","ru-RU":"Открыть удаленный рабочий стол к выбранному устройству","sl-SI":"Odpri oddaljeno namizje izbrane naprave","tr-TR":"Seçili cihaza Uzak Masaüstü aç","uk-UA":"Відкрити віддалений робочий стіл на вибраному пристрої","vi-VN":"Mở Máy tính Từ xa tới thiết bị đã chọn","zh-CN":"远程桌面连接到所选设备","zh-TW":"遠端桌面連線到所選裝置"}},{"gesture":"Ctrl + S","category":"Device","labels":{"bn":"SSH দিয়ে নির্বাচিত ডিভাইসে সংযোগ করুন","cs-CZ":"Připojit se k vybranému zařízení přes SSH","de-DE":"Über SSH mit dem ausgewählten Gerät verbinden","en-US":"Connect to the selected device over SSH","es":"Conectar al dispositivo seleccionado por SSH","fr-FR":"Se connecter à l'appareil sélectionné en SSH","hu-HU":"Csatlakozzon a kiválasztott eszközhöz SSH-n keresztül","it-IT":"Connettiti al dispositivo selezionato via SSH","ja-JP":"選択したデバイスに SSH で接続","kk-KZ":"Таңдалған құрылғыға SSH арқылы қосылу","nb-NO":"Koble til den valgte enheten via SSH","pl-PL":"Połącz się z wybranym urządzeniem przez SSH","pt-BR":"Conectar ao dispositivo selecionado por SSH","ru-RU":"Подключиться к выбранному устройству по SSH","sl-SI":"Poveži se z izbrano napravo prek SSH","tr-TR":"Seçili cihaza SSH ile bağlan","uk-UA":"Підключитися до вибраного пристрою через SSH","vi-VN":"Kết nối với thiết bị đã chọn qua SSH","zh-CN":"通过 SSH 连接到所选设备","zh-TW":"透過 SSH 連線到所選裝置"}},{"gesture":"Ctrl + Shift + S","category":"Device","labels":{"bn":"একটি SSH অ্যাকাউন্ট বেছে নিয়ে সংযোগ করুন","cs-CZ":"Vybrat účet SSH a připojit se","de-DE":"SSH-Konto wählen und verbinden","en-US":"Choose an SSH account and connect","es":"Elegir una cuenta SSH y conectar","fr-FR":"Choisir un compte SSH et se connecter","hu-HU":"Válasszon egy SSH-fiókot, és csatlakozzon","it-IT":"Scegli un account SSH e connettiti","ja-JP":"SSH アカウントを選んで接続","kk-KZ":"SSH тіркелгісін таңдап қосылу","nb-NO":"Velg en SSH-konto og koble til","pl-PL":"Wybierz konto SSH i połącz","pt-BR":"Escolher uma conta SSH e conectar","ru-RU":"Выбрать учетную запись SSH и подключиться","sl-SI":"Izberi račun SSH in se poveži","tr-TR":"Bir SSH hesabı seç ve bağlan","uk-UA":"Вибрати обліковий запис SSH і підключитися","vi-VN":"Chọn tài khoản SSH và kết nối","zh-CN":"选择 SSH 账户并连接","zh-TW":"選擇 SSH 帳戶並連線"}},{"gesture":"Ctrl + C","category":"Device","labels":{"bn":"নির্বাচিত IP ঠিকানা কপি করুন","cs-CZ":"Kopírovat vybranou IP adresu","de-DE":"Ausgewählte IP-Adresse kopieren","en-US":"Copy the selected IP address","es":"Copiar la dirección IP seleccionada","fr-FR":"Copier l'adresse IP sélectionnée","hu-HU":"Másolja a kiválasztott IP-címet","it-IT":"Copia l'indirizzo IP selezionato","ja-JP":"選択した IP アドレスをコピー","kk-KZ":"Таңдалған IP мекенжайын көшіру","nb-NO":"Kopier den valgte IP-adressen","pl-PL":"Kopiuj wybrany adres IP","pt-BR":"Copiar o endereço IP selecionado","ru-RU":"Копировать выбранный IP-адрес","sl-SI":"Kopiraj izbrani naslov IP","tr-TR":"Seçili IP adresini kopyala","uk-UA":"Копіювати вибрану IP-адресу","vi-VN":"Sao chép địa chỉ IP đã chọn","zh-CN":"复制所选 IP 地址","zh-TW":"複製所選 IP 位址"}},{"gesture":"Ctrl + Shift + C","category":"Device","labels":{"bn":"নির্বাচিত MAC ঠিকানা কপি করুন","cs-CZ":"Kopírovat vybranou MAC adresu","de-DE":"Ausgewählte MAC-Adresse kopieren","en-US":"Copy the selected MAC address","es":"Copiar la dirección MAC seleccionada","fr-FR":"Copier l'adresse MAC sélectionnée","hu-HU":"Másolja a kiválasztott MAC-címet","it-IT":"Copia l'indirizzo MAC selezionato","ja-JP":"選択した MAC アドレスをコピー","kk-KZ":"Таңдалған MAC мекенжайын көшіру","nb-NO":"Kopier den valgte MAC-adressen","pl-PL":"Kopiuj wybrany adres MAC","pt-BR":"Copiar o endereço MAC selecionado","ru-RU":"Копировать выбранный MAC-адрес","sl-SI":"Kopiraj izbrani naslov MAC","tr-TR":"Seçili MAC adresini kopyala","uk-UA":"Копіювати вибрану MAC-адресу","vi-VN":"Sao chép địa chỉ MAC đã chọn","zh-CN":"复制所选 MAC 地址","zh-TW":"複製所選 MAC 位址"}},{"gesture":"Ctrl + Alt + C","category":"Device","labels":{"bn":"নির্বাচিত হোস্টনাম কপি করুন","cs-CZ":"Kopírovat vybraný název hostitele","de-DE":"Ausgewählten Hostnamen kopieren","en-US":"Copy the selected hostname","es":"Copiar el nombre de host seleccionado","fr-FR":"Copier le nom d'hôte sélectionné","hu-HU":"Másolja ki a kiválasztott gazdagépnevet","it-IT":"Copia il nome host selezionato","ja-JP":"選択したホスト名をコピー","kk-KZ":"Таңдалған хост атауын көшіру","nb-NO":"Kopier det valgte vertsnavnet","pl-PL":"Kopiuj wybraną nazwę hosta","pt-BR":"Copiar o nome do host selecionado","ru-RU":"Копировать выбранное имя хоста","sl-SI":"Kopiraj izbrano ime gostitelja","tr-TR":"Seçili ana bilgisayar adını kopyala","uk-UA":"Копіювати вибране ім'я хоста","vi-VN":"Sao chép tên máy chủ đã chọn","zh-CN":"复制所选主机名","zh-TW":"複製所選主機名稱"}},{"gesture":"Shift + F10","category":"Device","labels":{"bn":"প্রসঙ্গ মেনু","cs-CZ":"Kontextová nabídka","de-DE":"Kontextmenü","en-US":"Context menu","es":"Menú contextual","fr-FR":"Menu contextuel","hu-HU":"Helyi menü","it-IT":"Menu contestuale","ja-JP":"コンテキストメニュー","kk-KZ":"Контекстік мәзір","nb-NO":"Kontekstmeny","pl-PL":"Menu kontekstowe","pt-BR":"Menu de contexto","ru-RU":"Контекстное меню","sl-SI":"Priročni meni","tr-TR":"Bağlam menüsü","uk-UA":"Контекстне меню","vi-VN":"Menu ngữ cảnh","zh-CN":"上下文菜单","zh-TW":"快顯功能表"}},{"gesture":"Menu","category":"Device","labels":{"bn":"প্রসঙ্গ মেনু","cs-CZ":"Kontextová nabídka","de-DE":"Kontextmenü","en-US":"Context menu","es":"Menú contextual","fr-FR":"Menu contextuel","hu-HU":"Helyi menü","it-IT":"Menu contestuale","ja-JP":"コンテキストメニュー","kk-KZ":"Контекстік мәзір","nb-NO":"Kontekstmeny","pl-PL":"Menu kontekstowe","pt-BR":"Menu de contexto","ru-RU":"Контекстное меню","sl-SI":"Priročni meni","tr-TR":"Bağlam menüsü","uk-UA":"Контекстне меню","vi-VN":"Menu ngữ cảnh","zh-CN":"上下文菜单","zh-TW":"快顯功能表"}},{"gesture":"F2","category":"Device","labels":{"bn":"ডিভাইসের নাম পরিবর্তন করুন","cs-CZ":"Přejmenovat zařízení","de-DE":"Gerät umbenennen","en-US":"Rename device","es":"Cambiar nombre del dispositivo","fr-FR":"Renommer l’appareil","hu-HU":"Eszköz átnevezése","it-IT":"Rinomina dispositivo","ja-JP":"デバイス名を変更","kk-KZ":"Құрылғы атын өзгерту","nb-NO":"Gi enheten nytt navn","pl-PL":"Zmień nazwę urządzenia","pt-BR":"Renomear dispositivo","ru-RU":"Переименовать устройство","sl-SI":"Preimenuj napravo","tr-TR":"Cihazı yeniden adlandır","uk-UA":"Перейменувати пристрій","vi-VN":"Đổi tên thiết bị","zh-CN":"重命名设备","zh-TW":"重新命名裝置"}},{"gesture":"Ctrl + Alt + N","category":"Device","labels":{"bn":"ম্যানুয়াল নাম মুছুন","cs-CZ":"Vymazat ruční název","de-DE":"Manuellen Namen löschen","en-US":"Clear manual name","es":"Borrar nombre manual","fr-FR":"Effacer le nom manuel","hu-HU":"Kézi név törlése","it-IT":"Cancella nome manuale","ja-JP":"手動名を消去","kk-KZ":"Қолмен берілген атауды тазалау","nb-NO":"Fjern manuelt navn","pl-PL":"Wyczyść nazwę ręczną","pt-BR":"Limpar nome manual","ru-RU":"Удалить заданное имя","sl-SI":"Odstrani ročno ime","tr-TR":"Elle verilen adı temizle","uk-UA":"Скинути ім'я, задане вручну","vi-VN":"Xóa tên thủ công","zh-CN":"清除手动名称","zh-TW":"清除手動名稱"}},{"gesture":"Ctrl + Alt + T","category":"Device","labels":{"bn":"ওভাররাইড সাফ করুন","cs-CZ":"Zrušit ruční nastavení","de-DE":"Überschreibung löschen","en-US":"Clear Override","es":"Borrar anulación","fr-FR":"Effacer le remplacement","hu-HU":"Felülbírálás törlése","it-IT":"Rimuovi override","ja-JP":"上書きをクリア","kk-KZ":"Қайта анықтауды тазалау","nb-NO":"Fjern overstyring","pl-PL":"Usuń nadpisanie","pt-BR":"Limpar substituição","ru-RU":"Сбросить переопределение","sl-SI":"Odstrani ročno nastavitev","tr-TR":"Geçersiz kılmayı temizle","uk-UA":"Скинути заміну","vi-VN":"Xóa ghi đè","zh-CN":"清除覆盖","zh-TW":"清除覆寫"}},{"gesture":"Ctrl + Shift + B","category":"Device","labels":{"bn":"বিশ্বস্ত ডিভাইস","cs-CZ":"Důvěryhodné zařízení","de-DE":"Vertrauenswürdiges Gerät","en-US":"Trusted device","es":"Dispositivo de confianza","fr-FR":"Appareil approuvé","hu-HU":"Megbízható eszköz","it-IT":"Dispositivo attendibile","ja-JP":"信頼済みデバイス","kk-KZ":"Сенімді құрылғы","nb-NO":"Klarert enhet","pl-PL":"Zaufane urządzenie","pt-BR":"Dispositivo confiável","ru-RU":"Доверенное устройство","sl-SI":"Zaupanja vredna naprava","tr-TR":"Güvenilir cihaz","uk-UA":"Довірений пристрій","vi-VN":"Thiết bị đáng tin cậy","zh-CN":"可信设备","zh-TW":"信任的裝置"}},{"gesture":"Ctrl + Shift + C","category":"Device","labels":{"bn":"সেবাসমূহ: ঠিকানা ও পোর্ট কপি করুন","cs-CZ":"Služby: Kopírovat adresu a port","de-DE":"Dienste: Adresse und Port kopieren","en-US":"Services: Copy endpoint","es":"Servicios: Copiar dirección y puerto","fr-FR":"Services: Copier l'adresse et le port","hu-HU":"Szolgáltatások: Cím és port másolása","it-IT":"Servizi: Copia indirizzo e porta","ja-JP":"サービス: アドレスとポートをコピー","kk-KZ":"Қызметтер: Мекенжай мен портты көшіру","nb-NO":"Tjenester: Kopier adresse og port","pl-PL":"Usługi: Kopiuj adres i port","pt-BR":"Serviços: Copiar endereço e porta","ru-RU":"Службы: Копировать адрес и порт","sl-SI":"Storitve: Kopiraj naslov storitve","tr-TR":"Hizmetler: Adresi ve bağlantı noktasını kopyala","uk-UA":"Служби: Копіювати адресу й порт","vi-VN":"Dịch vụ: Sao chép địa chỉ và cổng","zh-CN":"服务: 复制地址和端口","zh-TW":"服務: 複製位址與連接埠"}},{"gesture":"Ctrl + Shift + Y","category":"Device","labels":{"bn":"সেবাসমূহ: সেবার নাম কপি করুন","cs-CZ":"Služby: Kopírovat název služby","de-DE":"Dienste: Dienstnamen kopieren","en-US":"Services: Copy service name","es":"Servicios: Copiar nombre del servicio","fr-FR":"Services: Copier le nom du service","hu-HU":"Szolgáltatások: Szolgáltatásnév másolása","it-IT":"Servizi: Copia nome del servizio","ja-JP":"サービス: サービス名をコピー","kk-KZ":"Қызметтер: Қызмет атауын көшіру","nb-NO":"Tjenester: Kopier tjenestenavn","pl-PL":"Usługi: Kopiuj nazwę usługi","pt-BR":"Serviços: Copiar nome do serviço","ru-RU":"Службы: Копировать имя службы","sl-SI":"Storitve: Kopiraj ime storitve","tr-TR":"Hizmetler: Hizmet adını kopyala","uk-UA":"Служби: Копіювати назву служби","vi-VN":"Dịch vụ: Sao chép tên dịch vụ","zh-CN":"服务: 复制服务名称","zh-TW":"服務: 複製服務名稱"}},{"gesture":"Ctrl + Alt + P","category":"Device","labels":{"bn":"সেবাসমূহ: পোর্ট কপি করুন","cs-CZ":"Služby: Kopírovat port","de-DE":"Dienste: Port kopieren","en-US":"Services: Copy port","es":"Servicios: Copiar puerto","fr-FR":"Services: Copier le port","hu-HU":"Szolgáltatások: Port másolása","it-IT":"Servizi: Copia porta","ja-JP":"サービス: ポートをコピー","kk-KZ":"Қызметтер: Портты көшіру","nb-NO":"Tjenester: Kopier port","pl-PL":"Usługi: Kopiuj port","pt-BR":"Serviços: Copiar porta","ru-RU":"Службы: Копировать порт","sl-SI":"Storitve: Kopiraj vrata","tr-TR":"Hizmetler: Bağlantı noktasını kopyala","uk-UA":"Служби: Копіювати порт","vi-VN":"Dịch vụ: Sao chép cổng","zh-CN":"服务: 复制端口","zh-TW":"服務: 複製連接埠"}},{"gesture":"Ctrl + Shift + H","category":"Views","labels":{"bn":"স্ক্যান ইতিহাস: ইতিহাসের দৃশ্য বদলান","cs-CZ":"Historie skenování: Přepnout zobrazení historie","de-DE":"Scanverlauf: Verlaufsansicht wechseln","en-US":"Scan History: Switch history view","es":"Historial de análisis: Cambiar vista del historial","fr-FR":"Historique des analyses: Changer la vue de l’historique","hu-HU":"Vizsgálati előzmények: Előzmények nézetének váltása","it-IT":"Cronologia scansioni: Cambia vista della cronologia","ja-JP":"スキャン履歴: 履歴表示を切り替え","kk-KZ":"Сканерлеу тарихы: Тарих көрінісін ауыстыру","nb-NO":"Skanneloggen: Bytt historikkvisning","pl-PL":"Historia skanowania: Przełącz widok historii","pt-BR":"Histórico de verificações: Alternar a visualização do histórico","ru-RU":"История сканирования: Переключить вид истории","sl-SI":"Zgodovina pregledov: Zamenjaj pogled zgodovine","tr-TR":"Tarama geçmişi: Geçmiş görünümünü değiştir","uk-UA":"Історія сканувань: Змінити вигляд історії","vi-VN":"Lịch sử quét: Chuyển chế độ xem lịch sử","zh-CN":"扫描历史: 切换历史记录视图","zh-TW":"掃描歷史: 切換歷史記錄檢視"}},{"gesture":"Ctrl + Shift + Y","category":"Device","labels":{"bn":"স্ক্যান ইতিহাস: সংরক্ষিত ডিভাইসের বিবরণ কপি করুন","cs-CZ":"Historie skenování: Kopírovat uložené údaje zařízení","de-DE":"Scanverlauf: Gespeicherte Gerätedetails kopieren","en-US":"Scan History: Copy saved device details","es":"Historial de análisis: Copiar detalles del dispositivo guardado","fr-FR":"Historique des analyses: Copier les détails du périphérique enregistré","hu-HU":"Vizsgálati előzmények: Mentett eszközadatok másolása","it-IT":"Cronologia scansioni: Copia i dettagli del dispositivo salvato","ja-JP":"スキャン履歴: 保存されたデバイス情報をコピー","kk-KZ":"Сканерлеу тарихы: Сақталған құрылғы мәліметтерін көшіру","nb-NO":"Skanneloggen: Kopier lagrede enhetsdetaljer","pl-PL":"Historia skanowania: Kopiuj zapisane dane urządzenia","pt-BR":"Histórico de verificações: Copiar detalhes do dispositivo salvo","ru-RU":"История сканирования: Копировать сохранённые сведения об устройстве","sl-SI":"Zgodovina pregledov: Kopiraj shranjene podrobnosti naprave","tr-TR":"Tarama geçmişi: Kayıtlı cihaz ayrıntılarını kopyala","uk-UA":"Історія сканувань: Копіювати збережені відомості про пристрій","vi-VN":"Lịch sử quét: Sao chép chi tiết thiết bị đã lưu","zh-CN":"扫描历史: 复制已保存的设备详情","zh-TW":"掃描歷史: 複製已儲存的裝置詳細資料"}},{"gesture":"Enter","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: প্রোফাইল চালান","cs-CZ":"Profily skenování: Spustit profil","de-DE":"Scanprofile: Profil ausführen","en-US":"Scan Profiles: Run profile","es":"Perfiles de análisis: Ejecutar perfil","fr-FR":"Profils d’analyse: Exécuter le profil","hu-HU":"Vizsgálati profilok: Profil futtatása","it-IT":"Profili di scansione: Esegui profilo","ja-JP":"スキャンプロファイル: プロファイルを実行","kk-KZ":"Сканерлеу профильдері: Профильді іске қосу","nb-NO":"Skanneprofiler: Kjør profil","pl-PL":"Profile skanowania: Uruchom profil","pt-BR":"Perfis de verificação: Executar perfil","ru-RU":"Профили сканирования: Запустить профиль","sl-SI":"Profili pregledov: Zaženi profil","tr-TR":"Tarama profilleri: Profili çalıştır","uk-UA":"Профілі сканування: Запустити профіль","vi-VN":"Hồ sơ quét: Chạy hồ sơ","zh-CN":"扫描配置: 运行配置","zh-TW":"掃描設定檔: 執行設定檔"}},{"gesture":"Ctrl + L","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: লক্ষ্য লোড করুন","cs-CZ":"Profily skenování: Načíst cíl","de-DE":"Scanprofile: Ziel laden","en-US":"Scan Profiles: Load target","es":"Perfiles de análisis: Cargar destino","fr-FR":"Profils d’analyse: Charger la cible","hu-HU":"Vizsgálati profilok: Cél betöltése","it-IT":"Profili di scansione: Carica destinazione","ja-JP":"スキャンプロファイル: 対象を読み込む","kk-KZ":"Сканерлеу профильдері: Нысанды жүктеу","nb-NO":"Skanneprofiler: Last inn mål","pl-PL":"Profile skanowania: Wczytaj cel","pt-BR":"Perfis de verificação: Carregar destino","ru-RU":"Профили сканирования: Загрузить цель","sl-SI":"Profili pregledov: Naloži cilj","tr-TR":"Tarama profilleri: Hedefi yükle","uk-UA":"Профілі сканування: Завантажити ціль","vi-VN":"Hồ sơ quét: Tải mục tiêu","zh-CN":"扫描配置: 加载目标","zh-TW":"掃描設定檔: 載入目標"}},{"gesture":"Delete","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: প্রোফাইল মুছুন","cs-CZ":"Profily skenování: Odstranit profil","de-DE":"Scanprofile: Profil löschen","en-US":"Scan Profiles: Delete profile","es":"Perfiles de análisis: Eliminar perfil","fr-FR":"Profils d’analyse: Supprimer le profil","hu-HU":"Vizsgálati profilok: Profil törlése","it-IT":"Profili di scansione: Elimina profilo","ja-JP":"スキャンプロファイル: プロファイルを削除","kk-KZ":"Сканерлеу профильдері: Профильді жою","nb-NO":"Skanneprofiler: Slett profil","pl-PL":"Profile skanowania: Usuń profil","pt-BR":"Perfis de verificação: Excluir perfil","ru-RU":"Профили сканирования: Удалить профиль","sl-SI":"Profili pregledov: Izbriši profil","tr-TR":"Tarama profilleri: Profili sil","uk-UA":"Профілі сканування: Видалити профіль","vi-VN":"Hồ sơ quét: Xóa hồ sơ","zh-CN":"扫描配置: 删除配置","zh-TW":"掃描設定檔: 刪除設定檔"}},{"gesture":"Ctrl + Alt + D","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: পরে সব গভীর স্ক্যান করুন","cs-CZ":"Profily skenování: Poté hloubkově skenovat vše","de-DE":"Scanprofile: Danach Tiefenscan für alle","en-US":"Scan Profiles: Deep Scan All afterward","es":"Perfiles de análisis: Análisis profundo de todo después","fr-FR":"Profils d’analyse: Tout analyser en profondeur ensuite","hu-HU":"Vizsgálati profilok: Utána teljes mélyvizsgálat","it-IT":"Profili di scansione: Scansione profonda dopo","ja-JP":"スキャンプロファイル: 終了後にすべてを詳細スキャン","kk-KZ":"Сканерлеу профильдері: Содан кейін толық терең сканерлеу","nb-NO":"Skanneprofiler: Dypskann alle etterpå","pl-PL":"Profile skanowania: Potem głęboki skan wszystkiego","pt-BR":"Perfis de verificação: Verificação Profunda de Todos depois","ru-RU":"Профили сканирования: Затем глубокое сканирование всех","sl-SI":"Profili pregledov: Nato podrobno preglej vse naprave","tr-TR":"Tarama profilleri: Ardından tümünü derin tara","uk-UA":"Профілі сканування: Потім глибоке сканування всіх","vi-VN":"Hồ sơ quét: Sau đó quét sâu tất cả","zh-CN":"扫描配置: 随后深度扫描全部","zh-TW":"掃描設定檔: 之後深度掃描全部"}},{"gesture":"Ctrl + Alt + S","category":"Views","labels":{"bn":"স্ক্যান প্রোফাইল: বর্তমান প্রোফাইল সংরক্ষণ করুন","cs-CZ":"Profily skenování: Uložit aktuální profil","de-DE":"Scanprofile: Aktuelles Profil speichern","en-US":"Scan Profiles: Save current profile","es":"Perfiles de análisis: Guardar perfil actual","fr-FR":"Profils d’analyse: Enregistrer le profil actuel","hu-HU":"Vizsgálati profilok: Jelenlegi profil mentése","it-IT":"Profili di scansione: Salva profilo corrente","ja-JP":"スキャンプロファイル: 現在のプロファイルを保存","kk-KZ":"Сканерлеу профильдері: Ағымдағы профильді сақтау","nb-NO":"Skanneprofiler: Lagre gjeldende profil","pl-PL":"Profile skanowania: Zapisz bieżący profil","pt-BR":"Perfis de verificação: Salvar perfil atual","ru-RU":"Профили сканирования: Сохранить текущий профиль","sl-SI":"Profili pregledov: Shrani trenutni profil","tr-TR":"Tarama profilleri: Geçerli profili kaydet","uk-UA":"Профілі сканування: Зберегти поточний профіль","vi-VN":"Hồ sơ quét: Lưu hồ sơ hiện tại","zh-CN":"扫描配置: 保存当前配置","zh-TW":"掃描設定檔: 儲存目前設定檔"}},{"gesture":"Ctrl + C","category":"Device","labels":{"bn":"সংযোগ পর্যবেক্ষণ: ঠিকানা কপি করুন","cs-CZ":"Sledování připojení: Kopírovat adresu","de-DE":"Verbindungsüberwachung: Adresse kopieren","en-US":"Keep Alive: Copy address","es":"Supervisión de conexión: Copiar dirección","fr-FR":"Surveillance de connexion: Copier l'adresse","hu-HU":"Kapcsolatfigyelés: Cím másolása","it-IT":"Monitoraggio connessione: Copia indirizzo","ja-JP":"接続監視: アドレスをコピー","kk-KZ":"Қосылымды бақылау: Мекенжайды көшіру","nb-NO":"Tilkoblingsovervåking: Kopier adresse","pl-PL":"Monitorowanie połączenia: Kopiuj adres","pt-BR":"Monitoramento de conexão: Copiar endereço","ru-RU":"Наблюдение за соединением: Копировать адрес","sl-SI":"Nadzor povezave: Kopiraj naslov","tr-TR":"Bağlantı izleme: Adresi kopyala","uk-UA":"Спостереження за з'єднанням: Копіювати адресу","vi-VN":"Giám sát kết nối: Sao chép địa chỉ","zh-CN":"连接监控: 复制地址","zh-TW":"連線監控: 複製位址"}},{"gesture":"F3","category":"Device","labels":{"bn":"সংযোগ পর্যবেক্ষণ: আবার পরীক্ষা করুন","cs-CZ":"Sledování připojení: Zkontrolovat znovu","de-DE":"Verbindungsüberwachung: Erneut prüfen","en-US":"Keep Alive: Check again","es":"Supervisión de conexión: Comprobar de nuevo","fr-FR":"Surveillance de connexion: Vérifier à nouveau","hu-HU":"Kapcsolatfigyelés: Újraellenőrzés","it-IT":"Monitoraggio connessione: Controlla di nuovo","ja-JP":"接続監視: 再確認","kk-KZ":"Қосылымды бақылау: Қайта тексеру","nb-NO":"Tilkoblingsovervåking: Sjekk på nytt","pl-PL":"Monitorowanie połączenia: Sprawdź ponownie","pt-BR":"Monitoramento de conexão: Verificar novamente","ru-RU":"Наблюдение за соединением: Проверить снова","sl-SI":"Nadzor povezave: Preveri znova","tr-TR":"Bağlantı izleme: Yeniden denetle","uk-UA":"Спостереження за з'єднанням: Перевірити знову","vi-VN":"Giám sát kết nối: Kiểm tra lại","zh-CN":"连接监控: 重新检查","zh-TW":"連線監控: 重新檢查"}},{"gesture":"Ctrl + R","category":"Device","labels":{"bn":"সংযোগ পর্যবেক্ষণ: পরিসংখ্যান রিসেট করুন","cs-CZ":"Sledování připojení: Vynulovat statistiky","de-DE":"Verbindungsüberwachung: Statistik zurücksetzen","en-US":"Keep Alive: Reset statistics","es":"Supervisión de conexión: Restablecer estadísticas","fr-FR":"Surveillance de connexion: Réinitialiser les statistiques","hu-HU":"Kapcsolatfigyelés: Statisztika nullázása","it-IT":"Monitoraggio connessione: Azzera statistiche","ja-JP":"接続監視: 統計をリセット","kk-KZ":"Қосылымды бақылау: Статистиканы қалпына келтіру","nb-NO":"Tilkoblingsovervåking: Tilbakestill statistikk","pl-PL":"Monitorowanie połączenia: Zeruj statystyki","pt-BR":"Monitoramento de conexão: Redefinir estatísticas","ru-RU":"Наблюдение за соединением: Сбросить статистику","sl-SI":"Nadzor povezave: Ponastavi statistiko","tr-TR":"Bağlantı izleme: İstatistikleri sıfırla","uk-UA":"Спостереження за з'єднанням: Скинути статистику","vi-VN":"Giám sát kết nối: Đặt lại thống kê","zh-CN":"连接监控: 重置统计","zh-TW":"連線監控: 重設統計"}},{"gesture":"Delete","category":"Device","labels":{"bn":"সংযোগ পর্যবেক্ষণ: দেখা বন্ধ করুন","cs-CZ":"Sledování připojení: Přestat sledovat","de-DE":"Verbindungsüberwachung: Nicht mehr überwachen","en-US":"Keep Alive: Stop watching this target","es":"Supervisión de conexión: Dejar de supervisar este destino","fr-FR":"Surveillance de connexion: Ne plus surveiller cette cible","hu-HU":"Kapcsolatfigyelés: Cél figyelésének leállítása","it-IT":"Monitoraggio connessione: Smetti di monitorare questa destinazione","ja-JP":"接続監視: この対象の監視を停止","kk-KZ":"Қосылымды бақылау: Бұл нысананы бақылауды тоқтату","nb-NO":"Tilkoblingsovervåking: Slutt å overvåke dette målet","pl-PL":"Monitorowanie połączenia: Przestań monitorować ten cel","pt-BR":"Monitoramento de conexão: Parar de monitorar este destino","ru-RU":"Наблюдение за соединением: Прекратить наблюдение за целью","sl-SI":"Nadzor povezave: Prenehaj nadzorovati ta cilj","tr-TR":"Bağlantı izleme: Bu hedefi izlemeyi durdur","uk-UA":"Спостереження за з'єднанням: Припинити відстеження цієї цілі","vi-VN":"Giám sát kết nối: Dừng theo dõi mục tiêu này","zh-CN":"连接监控: 停止监视此目标","zh-TW":"連線監控: 停止監看此目標"}},{"gesture":"Ctrl + Shift + C","category":"App","labels":{"bn":"টার্মিনাল: কপি","cs-CZ":"Terminál: Kopírovat","de-DE":"Terminal: Kopieren","en-US":"Terminal: Copy","es":"Terminal: Copiar","fr-FR":"Terminal: Copier","hu-HU":"Terminál: Másolás","it-IT":"Terminale: Copia","ja-JP":"ターミナル: コピー","kk-KZ":"Терминал: Көшіру","nb-NO":"Terminal: Kopier","pl-PL":"Terminal: Kopiuj","pt-BR":"Terminal: Copiar","ru-RU":"Терминал: Копировать","sl-SI":"Terminal: Kopiraj","tr-TR":"Terminal: Kopyala","uk-UA":"Термінал: Копіювати","vi-VN":"Terminal: Sao chép","zh-CN":"终端: 复制","zh-TW":"終端機: 複製"}},{"gesture":"Ctrl + Shift + V","category":"App","labels":{"bn":"টার্মিনাল: পেস্ট","cs-CZ":"Terminál: Vložit","de-DE":"Terminal: Einfügen","en-US":"Terminal: Paste","es":"Terminal: Pegar","fr-FR":"Terminal: Coller","hu-HU":"Terminál: Beillesztés","it-IT":"Terminale: Incolla","ja-JP":"ターミナル: 貼り付け","kk-KZ":"Терминал: Қою","nb-NO":"Terminal: Lim inn","pl-PL":"Terminal: Wklej","pt-BR":"Terminal: Colar","ru-RU":"Терминал: Вставить","sl-SI":"Terminal: Prilepi","tr-TR":"Terminal: Yapıştır","uk-UA":"Термінал: Вставити","vi-VN":"Terminal: Dán","zh-CN":"终端: 粘贴","zh-TW":"終端機: 貼上"}},{"gesture":"Ctrl + Shift + A","category":"App","labels":{"bn":"টার্মিনাল: সব নির্বাচন","cs-CZ":"Terminál: Vybrat vše","de-DE":"Terminal: Alles auswählen","en-US":"Terminal: Select all","es":"Terminal: Seleccionar todo","fr-FR":"Terminal: Tout sélectionner","hu-HU":"Terminál: Összes kijelölése","it-IT":"Terminale: Seleziona tutto","ja-JP":"ターミナル: すべて選択","kk-KZ":"Терминал: Барлығын таңдау","nb-NO":"Terminal: Merk alt","pl-PL":"Terminal: Zaznacz wszystko","pt-BR":"Terminal: Selecionar tudo","ru-RU":"Терминал: Выделить все","sl-SI":"Terminal: Izberi vse","tr-TR":"Terminal: Tümünü seç","uk-UA":"Термінал: Виділити все","vi-VN":"Terminal: Chọn tất cả","zh-CN":"终端: 全选","zh-TW":"終端機: 全選"}},{"gesture":"Ctrl + Shift + Y","category":"App","labels":{"bn":"টার্মিনাল: সব কপি","cs-CZ":"Terminál: Kopírovat vše","de-DE":"Terminal: Alles kopieren","en-US":"Terminal: Copy All","es":"Terminal: Copiar todo","fr-FR":"Terminal: Tout copier","hu-HU":"Terminál: Összes másolása","it-IT":"Terminale: Copia tutto","ja-JP":"ターミナル: すべてコピー","kk-KZ":"Терминал: Барлығын көшіру","nb-NO":"Terminal: Kopier alt","pl-PL":"Terminal: Kopiuj wszystko","pt-BR":"Terminal: Copiar tudo","ru-RU":"Терминал: Копировать все","sl-SI":"Terminal: Kopiraj vse","tr-TR":"Terminal: Tümünü kopyala","uk-UA":"Термінал: Копіювати все","vi-VN":"Terminal: Sao chép tất cả","zh-CN":"终端: 复制全部","zh-TW":"終端機: 複製全部"}},{"gesture":"Ctrl + Shift + L","category":"App","labels":{"bn":"টার্মিনাল: পরিষ্কার","cs-CZ":"Terminál: Vymazat","de-DE":"Terminal: Leeren","en-US":"Terminal: Clear","es":"Terminal: Limpiar","fr-FR":"Terminal: Effacer","hu-HU":"Terminál: Törlés","it-IT":"Terminale: Pulisci","ja-JP":"ターミナル: クリア","kk-KZ":"Терминал: Тазарту","nb-NO":"Terminal: Tøm","pl-PL":"Terminal: Wyczyść","pt-BR":"Terminal: Limpar","ru-RU":"Терминал: Очистить","sl-SI":"Terminal: Počisti","tr-TR":"Terminal: Temizle","uk-UA":"Термінал: Очистити","vi-VN":"Terminal: Xóa","zh-CN":"终端: 清屏","zh-TW":"終端機: 清除"}},{"gesture":"Ctrl + G","category":"Topology","labels":{"bn":"টপোলজি বিন্যাস পরিবর্তন করুন","cs-CZ":"Procházet uspořádání topologie","de-DE":"Topologieanordnung wechseln","en-US":"Cycle topology arrangement","es":"Cambiar disposición de topología","fr-FR":"Changer la disposition de la topologie","hu-HU":"Topológiaelrendezés váltása","it-IT":"Cambia disposizione topologia","ja-JP":"トポロジの配置を切り替える","kk-KZ":"Топология орналасуын ауыстыру","nb-NO":"Bla gjennom topologiordninger","pl-PL":"Przełącz układ topologii","pt-BR":"Alternar a organização da topologia","ru-RU":"Переключить расположение топологии","sl-SI":"Zamenjaj razporeditev topologije","tr-TR":"Topoloji düzenini değiştir","uk-UA":"Перемкнути впорядкування топології","vi-VN":"Chuyển kiểu sắp xếp sơ đồ","zh-CN":"切换拓扑排列方式","zh-TW":"切換拓撲排列方式"}},{"gesture":"Ctrl + 1","category":"Topology","labels":{"bn":"টপোলজি সাজান ভূমিকা","cs-CZ":"Uspořádat topologii podle Role","de-DE":"Topologie anordnen nach Rolle","en-US":"Arrange topology by Role","es":"Organizar topología por Función","fr-FR":"Organiser la topologie par Rôle","hu-HU":"Topológia rendezése Szerep","it-IT":"Disponi topologia per Ruolo","ja-JP":"トポロジの並べ替え 役割","kk-KZ":"Топологияны реттеу Рөл","nb-NO":"Ordne topologien etter Rolle","pl-PL":"Rozmieść topologię według Rola","pt-BR":"Organizar a topologia por Função","ru-RU":"Упорядочить топологию по Роль","sl-SI":"Razporedi topologijo po Vloga","tr-TR":"Topolojiyi şuna göre düzenle Rol","uk-UA":"Упорядкувати топологію за Роль","vi-VN":"Sắp xếp sơ đồ theo Vai trò","zh-CN":"拓扑排列方式 角色","zh-TW":"拓撲排列方式 角色"}},{"gesture":"Ctrl + 2","category":"Topology","labels":{"bn":"টপোলজি সাজান ধরন","cs-CZ":"Uspořádat topologii podle Typ","de-DE":"Topologie anordnen nach Typ","en-US":"Arrange topology by Type","es":"Organizar topología por Tipo","fr-FR":"Organiser la topologie par Type","hu-HU":"Topológia rendezése Típus","it-IT":"Disponi topologia per Tipo","ja-JP":"トポロジの並べ替え 種類","kk-KZ":"Топологияны реттеу Түрі","nb-NO":"Ordne topologien etter Type","pl-PL":"Rozmieść topologię według Typ","pt-BR":"Organizar a topologia por Tipo","ru-RU":"Упорядочить топологию по Тип","sl-SI":"Razporedi topologijo po Vrsta","tr-TR":"Topolojiyi şuna göre düzenle Tür","uk-UA":"Упорядкувати топологію за Тип","vi-VN":"Sắp xếp sơ đồ theo Loại","zh-CN":"拓扑排列方式 类型","zh-TW":"拓撲排列方式 類型"}},{"gesture":"Ctrl + 3","category":"Topology","labels":{"bn":"টপোলজি সাজান IP ঠিকানা","cs-CZ":"Uspořádat topologii podle IP adresa","de-DE":"Topologie anordnen nach IP-Adresse","en-US":"Arrange topology by IP Address","es":"Organizar topología por Dirección IP","fr-FR":"Organiser la topologie par Adresse IP","hu-HU":"Topológia rendezése IP-cím","it-IT":"Disponi topologia per Indirizzo IP","ja-JP":"トポロジの並べ替え IP アドレス","kk-KZ":"Топологияны реттеу IP мекенжайы","nb-NO":"Ordne topologien etter IP-adresse","pl-PL":"Rozmieść topologię według Adres IP","pt-BR":"Organizar a topologia por Endereço IP","ru-RU":"Упорядочить топологию по IP-адрес","sl-SI":"Razporedi topologijo po Naslov IP","tr-TR":"Topolojiyi şuna göre düzenle IP Adresi","uk-UA":"Упорядкувати топологію за IP-адреса","vi-VN":"Sắp xếp sơ đồ theo Địa chỉ IP","zh-CN":"拓扑排列方式 IP 地址","zh-TW":"拓撲排列方式 IP 位址"}},{"gesture":"Ctrl + 4","category":"Topology","labels":{"bn":"টপোলজি সাজান প্রস্তুতকারক","cs-CZ":"Uspořádat topologii podle Výrobce","de-DE":"Topologie anordnen nach Hersteller","en-US":"Arrange topology by Vendor","es":"Organizar topología por Fabricante","fr-FR":"Organiser la topologie par Fabricant","hu-HU":"Topológia rendezése Gyártó","it-IT":"Disponi topologia per Produttore","ja-JP":"トポロジの並べ替え ベンダー","kk-KZ":"Топологияны реттеу Өндіруші","nb-NO":"Ordne topologien etter Produsent","pl-PL":"Rozmieść topologię według Producent","pt-BR":"Organizar a topologia por Fabricante","ru-RU":"Упорядочить топологию по Производитель","sl-SI":"Razporedi topologijo po Proizvajalec","tr-TR":"Topolojiyi şuna göre düzenle Üretici","uk-UA":"Упорядкувати топологію за Виробник","vi-VN":"Sắp xếp sơ đồ theo Nhà sản xuất","zh-CN":"拓扑排列方式 厂商","zh-TW":"拓撲排列方式 廠商"}},{"gesture":"Ctrl + Shift + 1","category":"Toolbar","labels":{"bn":"ছোট আইকন","cs-CZ":"Malé ikony","de-DE":"Kleine Symbole","en-US":"Small icons","es":"Iconos pequeños","fr-FR":"Petites icônes","hu-HU":"Kis ikonok","it-IT":"Icone piccole","ja-JP":"小さいアイコン","kk-KZ":"Кішкентай белгішелер","nb-NO":"Små ikoner","pl-PL":"Małe ikony","pt-BR":"Ícones pequenos","ru-RU":"Маленькие иконки","sl-SI":"Majhne ikone","tr-TR":"Küçük simgeler","uk-UA":"Малі значки","vi-VN":"Biểu tượng nhỏ","zh-CN":"小图标","zh-TW":"小圖示"}},{"gesture":"Ctrl + Shift + 2","category":"Toolbar","labels":{"bn":"বড় আইকন","cs-CZ":"Velké ikony","de-DE":"Große Symbole","en-US":"Large icons","es":"Iconos grandes","fr-FR":"Grandes icônes","hu-HU":"Nagy ikonok","it-IT":"Icone grandi","ja-JP":"大きいアイコン","kk-KZ":"Үлкен белгішелер","nb-NO":"Store ikoner","pl-PL":"Duże ikony","pt-BR":"Ícones grandes","ru-RU":"Большие иконки","sl-SI":"Velike ikone","tr-TR":"Büyük simgeler","uk-UA":"Великі значки","vi-VN":"Biểu tượng lớn","zh-CN":"大图标","zh-TW":"大圖示"}},{"gesture":"Ctrl + Shift + 3","category":"Toolbar","labels":{"bn":"লেখা নেই","cs-CZ":"Bez textu","de-DE":"Kein Text","en-US":"No text","es":"Sin texto","fr-FR":"Sans texte","hu-HU":"Nincs szöveg","it-IT":"Nessun testo","ja-JP":"文字なし","kk-KZ":"Мәтін жоқ","nb-NO":"Ingen tekst","pl-PL":"Brak tekstu","pt-BR":"Sem texto","ru-RU":"Нет текста","sl-SI":"Brez besedila","tr-TR":"Metin yok","uk-UA":"Без тексту","vi-VN":"Không có văn bản","zh-CN":"无文字","zh-TW":"無文字"}},{"gesture":"Ctrl + Shift + 4","category":"Toolbar","labels":{"bn":"আইকনের পাশে লেখা","cs-CZ":"Text vedle ikon","de-DE":"Text neben Symbol","en-US":"Text beside icons","es":"Texto junto a iconos","fr-FR":"Texte à côté des icônes","hu-HU":"Szöveg az ikonok mellett","it-IT":"Testo accanto alle icone","ja-JP":"アイコンの横にテキスト","kk-KZ":"Мәтін белгішенің қасында","nb-NO":"Tekst ved siden av ikoner","pl-PL":"Tekst obok ikon","pt-BR":"Texto ao lado dos ícones","ru-RU":"Текст рядом с иконками","sl-SI":"Besedilo ob ikonah","tr-TR":"Simge yanında metin","uk-UA":"Текст поруч зі значками","vi-VN":"Văn bản cạnh biểu tượng","zh-CN":"图标加文本","zh-TW":"圖示加文字"}},{"gesture":"Ctrl + Shift + 5","category":"Toolbar","labels":{"bn":"আইকনের নিচে লেখা","cs-CZ":"Text pod ikonami","de-DE":"Text unter Symbol","en-US":"Text under icons","es":"Texto bajo los iconos","fr-FR":"Texte sous les icônes","hu-HU":"Szöveg az ikonok alatt","it-IT":"Testo sotto le icone","ja-JP":"アイコンの下にテキスト","kk-KZ":"Мәтін белгішенің астында","nb-NO":"Tekst under ikoner","pl-PL":"Tekst pod ikonami","pt-BR":"Texto abaixo dos ícones","ru-RU":"Текст под иконками","sl-SI":"Besedilo pod ikonami","tr-TR":"Simge altında metin","uk-UA":"Текст під значками","vi-VN":"Văn bản dưới biểu tượng","zh-CN":"图标下方文本","zh-TW":"圖示下方文字"}},{"gesture":"Ctrl + Shift + 6","category":"Toolbar","labels":{"bn":"শুধু লেখা","cs-CZ":"Pouze text","de-DE":"Nur Text","en-US":"Text only","es":"Solo texto","fr-FR":"Texte seul","hu-HU":"Csak szöveg","it-IT":"Solo testo","ja-JP":"テキストのみ","kk-KZ":"Тек мәтін","nb-NO":"Bare tekst","pl-PL":"Tylko tekst","pt-BR":"Somente texto","ru-RU":"Только текст","sl-SI":"Samo besedilo","tr-TR":"Yalnızca metin","uk-UA":"Лише текст","vi-VN":"Chỉ văn bản","zh-CN":"仅文本","zh-TW":"僅文字"}},{"gesture":"Ctrl + Shift + +","category":"App","labels":{"bn":"অ্যাপ বড় করুন","cs-CZ":"Zvětšit aplikaci","de-DE":"App vergrößern","en-US":"Make the app bigger","es":"Agrandar la aplicación","fr-FR":"Agrandir l'application","hu-HU":"Tedd nagyobbá az alkalmazást","it-IT":"Ingrandisci l'app","ja-JP":"アプリを大きくする","kk-KZ":"Қолданбаны үлкейту","nb-NO":"Gjør appen større","pl-PL":"Powiększ aplikację","pt-BR":"Aumentar o app","ru-RU":"Увеличить приложение","sl-SI":"Povečaj prikaz programa","tr-TR":"Uygulamayı büyüt","uk-UA":"Збільшити застосунок","vi-VN":"Phóng to ứng dụng","zh-CN":"放大应用","zh-TW":"放大應用程式"}},{"gesture":"Ctrl + Shift + -","category":"App","labels":{"bn":"অ্যাপ ছোট করুন","cs-CZ":"Zmenšit aplikaci","de-DE":"App verkleinern","en-US":"Make the app smaller","es":"Reducir la aplicación","fr-FR":"Réduire l'application","hu-HU":"Csökkentse az alkalmazást","it-IT":"Rimpicciolisci l'app","ja-JP":"アプリを小さくする","kk-KZ":"Қолданбаны кішірейту","nb-NO":"Gjør appen mindre","pl-PL":"Pomniejsz aplikację","pt-BR":"Diminuir o app","ru-RU":"Уменьшить приложение","sl-SI":"Pomanjšaj prikaz programa","tr-TR":"Uygulamayı küçült","uk-UA":"Зменшити застосунок","vi-VN":"Thu nhỏ ứng dụng","zh-CN":"缩小应用","zh-TW":"縮小應用程式"}},{"gesture":"Ctrl + Shift + 0","category":"App","labels":{"bn":"অ্যাপের আকার রিসেট করুন","cs-CZ":"Obnovit výchozí velikost aplikace","de-DE":"App-Größe zurücksetzen","en-US":"Reset the app size","es":"Restablecer el tamaño de la aplicación","fr-FR":"Réinitialiser la taille de l'application","hu-HU":"Állítsa vissza az alkalmazás méretét","it-IT":"Ripristina la dimensione dell'app","ja-JP":"アプリのサイズをリセット","kk-KZ":"Қолданба өлшемін қалпына келтіру","nb-NO":"Tilbakestill appstørrelsen","pl-PL":"Przywróć domyślny rozmiar","pt-BR":"Redefinir o tamanho do app","ru-RU":"Сбросить размер приложения","sl-SI":"Ponastavi velikost programa","tr-TR":"Uygulama boyutunu sıfırla","uk-UA":"Скинути розмір застосунку","vi-VN":"Đặt lại kích thước ứng dụng","zh-CN":"重置应用大小","zh-TW":"重設應用程式大小"}},{"gesture":"Alt + Shift + G","category":"App","labels":{"bn":"ফিল্ম গ্রেইন","cs-CZ":"Filmové zrno","de-DE":"Filmkorn","en-US":"Film Grain","es":"Grano de película","fr-FR":"Grain de film","hu-HU":"Filmszemcse","it-IT":"Grana pellicola","ja-JP":"フィルムグレイン","kk-KZ":"Пленка түйіршігі","nb-NO":"Filmkorn","pl-PL":"Ziarno filmowe","pt-BR":"Granulação de filme","ru-RU":"Зернистость плёнки","sl-SI":"Filmsko zrno","tr-TR":"Film greni","uk-UA":"Зернистість плівки","vi-VN":"Hạt phim","zh-CN":"胶片颗粒","zh-TW":"底片顆粒"}},{"gesture":"F1","category":"App","labels":{"bn":"কীবোর্ড শর্টকাট দেখান বা লুকান","cs-CZ":"Zobrazit nebo skrýt klávesové zkratky","de-DE":"Tastenkürzel ein- oder ausblenden","en-US":"Show or hide keyboard shortcuts","es":"Mostrar u ocultar los atajos de teclado","fr-FR":"Afficher ou masquer les raccourcis clavier","hu-HU":"Billentyűparancsok megjelenítése vagy elrejtése","it-IT":"Mostra o nascondi le scorciatoie da tastiera","ja-JP":"キーボードショートカットを表示または非表示","kk-KZ":"Перне тіркесімдерін көрсету немесе жасыру","nb-NO":"Vis eller skjul hurtigtaster","pl-PL":"Pokaż lub ukryj skróty klawiszowe","pt-BR":"Mostrar ou ocultar os atalhos de teclado","ru-RU":"Показать или скрыть сочетания клавиш","sl-SI":"Prikaži ali skrij bližnjice na tipkovnici","tr-TR":"Klavye kısayollarını göster veya gizle","uk-UA":"Показати або сховати сполучення клавіш","vi-VN":"Hiện hoặc ẩn phím tắt","zh-CN":"显示或隐藏键盘快捷键","zh-TW":"顯示或隱藏鍵盤快捷鍵"}},{"gesture":"F12","category":"App","labels":{"bn":"সম্পর্কে উইন্ডোটি খুলুন","cs-CZ":"Otevřít okno O aplikaci","de-DE":"Das Info-Fenster öffnen","en-US":"Open the About window","es":"Abrir la ventana Acerca de","fr-FR":"Ouvrir la fenêtre À propos","hu-HU":"Nyissa meg a névjegy ablakát","it-IT":"Apri la finestra Informazioni","ja-JP":"バージョン情報ウィンドウを開く","kk-KZ":"Қолданба туралы терезесін ашу","nb-NO":"Åpne Om-vinduet","pl-PL":"Otwórz okno O programie","pt-BR":"Abrir a janela Sobre","ru-RU":"Открыть окно «О программе»","sl-SI":"Odpri okno o programu","tr-TR":"Hakkında penceresini aç","uk-UA":"Відкрити вікно «Про програму»","vi-VN":"Mở cửa sổ Giới thiệu","zh-CN":"打开关于窗口","zh-TW":"開啟關於視窗"}}]; diff --git a/scan-landing/technical.html b/scan-landing/technical.html index d1ada551..30ffdbce 100644 --- a/scan-landing/technical.html +++ b/scan-landing/technical.html @@ -110,7 +110,7 @@ - + @@ -493,8 +493,8 @@

Privacy & footprint

- - + + "))) + Require(Single(await Probe(server.Port), server.Port).Login == AccessLogin.Unknown, "Password fields in comments or scripts do not count."); + using (var server = new Server(IPAddress.Loopback, _ => Response("200 OK", "Open page"))) + { + var entry = Single(await Probe(server.Port), server.Port); + Require(entry.HttpStatus == 200 && entry.Login == AccessLogin.Unknown, "A plain 200 says nothing about logins."); + } + using (var server = new Server(IPAddress.Loopback, _ => Response("403 Forbidden", ""))) + Require(Single(await Probe(server.Port), server.Port).Login == AccessLogin.Unknown, "A 403 is not a login request."); + + // A same-address redirect: the first port records the redirect, the second the page. + using (var panel = new Server(IPAddress.Loopback, _ => Response("200 OK", "Local panel
"))) + using (var front = new Server(IPAddress.Loopback, _ => "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:" + panel.Port + "/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + { + var device = await Probe(front.Port); + Require(Single(device, front.Port).HttpStatus == 302 && Single(device, front.Port).Login == AccessLogin.Unknown, + "The redirecting port records the redirect, not the page it led to."); + Require(Single(device, panel.Port).HttpStatus == 200 && Single(device, panel.Port).Login == AccessLogin.LoginForm, + "The page is recorded under the port that served it."); + } + + // Normal scans stop at the first useful page; deep scans check every web port. + using (var first = new Server(IPAddress.Loopback, _ => Response("200 OK", "First"))) + using (var second = new Server(IPAddress.Loopback, _ => Response("401 Unauthorized", "", "WWW-Authenticate: NTLM\r\n"))) + { + var normal = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [first.Port, second.Port] }; + await NetworkScanner.ProbeHttpAsync(normal, IPAddress.Loopback, deep: true); + Require(normal.Access!.Count == 1 && second.Requests == 0, "A normal web probe stops after the first useful page."); + var deep = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [first.Port, second.Port] }; + await NetworkScanner.ProbeHttpAsync(deep, IPAddress.Loopback, deep: true, everyPort: true, CancellationToken.None); + Require(deep.Access!.Count == 2 && Single(deep, second.Port).AuthScheme == "NTLM", "A deep probe records every web port."); + Require(deep.HttpTitle == "First", "Classification still reads the first useful page."); + } + + // The access list adds every other open port, labeled by what is known about it. + var host = new NetworkDevice + { + IpAddress = "192.0.2.10", OpenPorts = [22, 443, 3389, 9100], SshBanner = "SSH-2.0-OpenSSH_9.6", + Access = [new AccessEndpoint { Protocol = "http", Port = 80, Evidence = AccessEvidence.HttpResponse, HttpStatus = 200 }], + }; + var list = DeviceAccess.ForDevice(host); + Require(list.Select(e => e.Protocol + ":" + e.Port).SequenceEqual(["ssh:22", "http:80", "https:443", "rdp:3389", "tcp:9100"]), + "Every endpoint is listed once, by port: " + string.Join(", ", list.Select(e => e.Protocol + ":" + e.Port))); + Require(list[0].Evidence == AccessEvidence.SshBanner && list[2].Evidence == AccessEvidence.PortOpen && list[2].ObservedAt == null, + "Port-only endpoints say so and invent no time."); + Require(DeviceAccess.BestWeb(host)!.Port == 80, "The default web address is one that answered, even over a guessed HTTPS port."); + host.Access = null; + Require(DeviceAccess.BestWeb(host)!.Protocol == "https", "With no answers, the guessed HTTPS port comes first."); + Require(DeviceAccess.ForDevice(new NetworkDevice { IpAddress = "printer.local", OpenPorts = [80] }).Count == 0, "Only numeric addresses get endpoints."); + + // Addresses are built from the device's numeric address and the endpoint alone. + Require(DeviceAccess.Address("192.0.2.10", new AccessEndpoint { Protocol = "https", Port = 8443 }) == "https://192.0.2.10:8443/", "Web addresses use the scheme and port."); + Require(DeviceAccess.Address("192.0.2.10", new AccessEndpoint { Protocol = "ssh", Port = 2222 }) == "192.0.2.10:2222", "Other endpoints are host and port."); + Require(DeviceAccess.Address("192.0.2.10", new AccessEndpoint { Protocol = "javascript", Port = 80 }) == null && + DeviceAccess.Address("192.0.2.10", new AccessEndpoint { Protocol = "http", Port = 0 }) == null && + DeviceAccess.Address("evil.example", new AccessEndpoint { Protocol = "http", Port = 80 }) == null, + "Unknown schemes, bad ports and names are refused."); + + string json = JsonSerializer.Serialize(new AccessEndpoint { Protocol = "https", Port = 443, Evidence = AccessEvidence.HttpResponse, Login = AccessLogin.LoginForm }); + Require(json.Contains("\"HttpResponse\"") && json.Contains("\"LoginForm\""), "JSON uses readable evidence and login names: " + json); + + Console.WriteLine("PASS: access records what each endpoint answered and claims no login it did not see."); + } + + private static async Task Probe(int port) + { + var device = new NetworkDevice { IpAddress = "127.0.0.1", OpenPorts = [port] }; + await NetworkScanner.ProbeHttpAsync(device, IPAddress.Loopback, deep: true); + return device; + } + + private static AccessEndpoint Single(NetworkDevice device, int port) => + device.Access?.SingleOrDefault(e => e.Port == port) ?? throw new InvalidOperationException("No access entry for port " + port + "."); + + private static string Response(string status, string body, string headers = "") => + "HTTP/1.1 " + status + "\r\n" + headers + "Content-Length: " + body.Length + "\r\nConnection: close\r\n\r\n" + body; + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } +} diff --git a/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs b/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs index 0f4b6e50..5e8ee502 100644 --- a/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs +++ b/tests/KillerScan.Engine.Tests/HttpRedirectTests.cs @@ -64,7 +64,8 @@ private static string Final() const string body = "Local panel"; return "HTTP/1.1 200 OK\r\nServer: Local-Fixture\r\nContent-Length: " + body.Length + "\r\nConnection: close\r\n\r\n" + body; } - private sealed class Server : IDisposable + // Shared with AccessTests, which needs the same loopback web fixtures. + internal sealed class Server : IDisposable { private readonly TcpListener _listener; private readonly CancellationTokenSource _stop = new(); diff --git a/tests/KillerScan.Engine.Tests/Program.cs b/tests/KillerScan.Engine.Tests/Program.cs index 5928e6c7..bf819d9e 100644 --- a/tests/KillerScan.Engine.Tests/Program.cs +++ b/tests/KillerScan.Engine.Tests/Program.cs @@ -24,6 +24,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--dns-budget" })) { await DnsBudgetTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--binding" })) { await BindingTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--recognition" })) { await RecognitionTests.Run(); return 0; } + if (args.SequenceEqual(new[] { "--access" })) { await AccessTests.Run(); return 0; } await Run("Scan options preserve defaults and validate feature choices", ScanOptionsTests.Run); await Run("Targets parse ranges, de-duplicate overlaps and refuse bad input", Targets); await Run("The address ceiling counts unique addresses and keeps first-seen order", TargetCeiling); @@ -31,6 +32,7 @@ private static async Task Main(string[] args) await Run("ARP output parsing keeps IPv4 entries with full MAC addresses", ArpParsing); await Run("Classifier honors manual types, the gateway, DNS and hostname rules", Classifier); await Run("Recognition grades a labeled corpus and records its evidence", RecognitionTests.Run); + await Run("Access records what each endpoint answered", AccessTests.Run); await Run("Bundled vendor database loads from the engine assembly", Vendors); await Run("Status text is English, numeric and free of double hyphens", StatusText); await Run("Quick scan reports stages, devices and host hooks through a fake platform", QuickScan); diff --git a/tests/SpeedTest.Tests/AccessUiTests.cs b/tests/SpeedTest.Tests/AccessUiTests.cs new file mode 100644 index 00000000..0ec5b707 --- /dev/null +++ b/tests/SpeedTest.Tests/AccessUiTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using KillerScan.Controls; +using KillerScan.Engine; +using KillerScan.Services; + +// The Access submenu lists each way into a device from what the scan recorded, and each entry +// opens with the existing browser, SSH or Remote Desktop action. Nothing here sends traffic, +// and no entry is clicked that would touch the real clipboard. +internal static class AccessUiTests +{ + internal static void Run() + { + SynchronizationContext.SetSynchronizationContext(new System.Windows.Threading.DispatcherSynchronizationContext( + System.Windows.Threading.Dispatcher.CurrentDispatcher)); + bool demo = DemoData.Enabled; + DemoData.Enabled = false; + try + { + var seen = new DateTimeOffset(2026, 10, 21, 18, 30, 0, TimeSpan.Zero); + var panel = new NetworkDevice + { + IpAddress = "192.0.2.10", OpenPorts = [22, 80, 8443, 9100], SshBanner = "SSH-2.0-OpenSSH_9.6", + Access = [new AccessEndpoint { Protocol = "https", Port = 8443, Evidence = AccessEvidence.HttpResponse, HttpStatus = 200, Login = AccessLogin.LoginForm, ObservedAt = seen }], + }; + var quiet = new NetworkDevice { IpAddress = "192.0.2.11" }; + + using var workspace = new ScanWorkspace("192.0.2.0/24", false); + workspace.LoadSnapshot("192.0.2.0/24", [panel, quiet]); + var grid = (DataGrid)workspace.FindName("ResultsGrid"); + var menu = (MenuItem)workspace.FindName("AccessMenuItem"); + string Res(string key) => (string)Application.Current.FindResource(key); + + List Open(NetworkDevice device) + { + grid.SelectedItem = device; + menu.RaiseEvent(new RoutedEventArgs(MenuItem.SubmenuOpenedEvent, menu)); + return [.. menu.Items.OfType()]; + } + + var items = Open(panel); + var headers = items.Select(i => i.Header as string).ToList(); + string Item(string protocol, int port, string evidence) => string.Format(Res("Str_Access_Item"), protocol, port, evidence); + Require(headers.SequenceEqual( + [ + Item("SSH", 22, Res("Str_Access_Banner")), Item("HTTP", 80, Res("Str_Access_PortOpen")), + Item("HTTPS", 8443, Res("Str_Access_LoginForm")), Item("TCP", 9100, Res("Str_Access_PortOpen")), + Res("Str_Access_CopyMenu"), + ]), "Access lists every endpoint by port, then Copy Address: " + string.Join(" | ", headers)); + var copy = items.Last(); + Require(copy.Items.OfType().Select(i => (string)i.Header).SequenceEqual( + ["192.0.2.10:22", "http://192.0.2.10:80/", "https://192.0.2.10:8443/", "192.0.2.10:9100"]), + "Copy Address offers each endpoint's address."); + string tip = (string)items[2].ToolTip; + Require(tip.Contains(Res("Str_Access_Tip_LoginForm")) && tip.Contains(string.Format(Res("Str_Access_Tip_Seen"), seen.ToLocalTime().ToString("g"))), + "A recorded response explains itself and says when it was seen: " + tip); + Require(((string)items[1].ToolTip).StartsWith(Res("Str_Access_Tip_PortOpen")) && ((string)items[3].ToolTip).Contains(Res("Str_Access_Tip_Copy")), + "Port-only endpoints say the protocol is a guess, and unknown ones say a click copies."); + + // Clicking opens through the existing actions, with the endpoint's port. + var raised = new List<(string Action, int? Port)>(); + workspace.DeviceAction += (_, e) => raised.Add((e.Action, e.ServicePort)); + items[2].RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + items[0].RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + Require(raised.SequenceEqual([("Browser", (int?)8443), ("Ssh", (int?)22)]), "Endpoints open with the browser and SSH actions on their port."); + + Require(Open(quiet).Single() is { IsEnabled: false } none && (string)none.Header == Res("Str_Access_None"), "A device with no open ports says so."); + + // Open in Browser uses the endpoint that answered, not plain http://address. + Require(ScanWorkspace.BrowserAddress(panel) == "https://192.0.2.10:8443/", "Open in Browser prefers the web endpoint that answered."); + Require(ScanWorkspace.BrowserAddress(panel, 80) == "http://192.0.2.10:80/" && ScanWorkspace.BrowserAddress(panel, 9100) == null, + "A service opens with its own port's scheme, and a non-web port does not open in a browser."); + Require(ScanWorkspace.BrowserAddress(quiet) == "http://192.0.2.11/", "With no web port, Open in Browser falls back to http://address/."); + + // Exports carry the same evidence. + var lines = ReportExport.BuildCsv([panel]).Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + Require(lines[0].EndsWith(",Confidence,Evidence,Access") && + lines[1].EndsWith("\"ssh:22 banner; http:80 open; https:8443 200 login-form; tcp:9100 open\""), + "CSV ends with an Access column: " + lines[1]); + string html = ReportExport.BuildHtml([panel], "192.0.2.0/24", "dark"); + Require(html.Contains(System.Net.WebUtility.HtmlEncode(Item("HTTPS", 8443, Res("Str_Access_LoginForm")))), "The HTML ports cell lists the endpoints on hover."); + } + finally { DemoData.Enabled = demo; } + Console.WriteLine("PASS: the Access menu lists each endpoint, opens it with the right action and claims no login."); + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } +} diff --git a/tests/SpeedTest.Tests/Program.cs b/tests/SpeedTest.Tests/Program.cs index f05aa362..6fdac4e2 100644 --- a/tests/SpeedTest.Tests/Program.cs +++ b/tests/SpeedTest.Tests/Program.cs @@ -88,6 +88,7 @@ private static async Task Main(string[] args) if (args.SequenceEqual(new[] { "--locations" })) { LocationTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--locations-sidebar" })) { HistoryUiTests.Isolated = LocationsSidebarTests.Run; await HistoryUiTests.Run(lifecycleOnly: true); return 0; } if (args.SequenceEqual(new[] { "--location-workspace" })) { HistoryUiTests.Isolated = LocationWorkspaceTests.Run; await HistoryUiTests.Run(lifecycleOnly: true); return 0; } + if (args.SequenceEqual(new[] { "--access-ui" })) { HistoryUiTests.Isolated = AccessUiTests.Run; await HistoryUiTests.Run(lifecycleOnly: true); return 0; } if (args.SequenceEqual(new[] { "--recognition-ui" })) { HistoryUiTests.Isolated = RecognitionUiTests.Run; await HistoryUiTests.Run(lifecycleOnly: true); return 0; } if (args.SequenceEqual(new[] { "--report-sort" })) { await ReportSortTests.Run(); return 0; } if (args.SequenceEqual(new[] { "--command-line-path" })) { await CommandLinePathTests.Run(); return 0; } diff --git a/tests/SpeedTest.Tests/RecognitionUiTests.cs b/tests/SpeedTest.Tests/RecognitionUiTests.cs index fc3c705a..2f654922 100644 --- a/tests/SpeedTest.Tests/RecognitionUiTests.cs +++ b/tests/SpeedTest.Tests/RecognitionUiTests.cs @@ -82,9 +82,9 @@ string Tip(NetworkDevice device) // Exports carry the evidence: CSV adds two trailing columns, HTML a hover title. string csv = ReportExport.BuildCsv([shown, old]); var lines = csv.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); - Require(lines[0] == "IP Address,Hostname,MAC Address,Vendor,Type,Open Ports,Confidence,Evidence", "CSV adds Confidence and Evidence at the end."); + Require(lines[0] == "IP Address,Hostname,MAC Address,Vendor,Type,Open Ports,Confidence,Evidence,Access", "CSV adds Confidence and Evidence at the end."); Require(lines[1].Contains("\"High\"") && lines[1].Contains("mdns: _ipp._tcp"), "A CSV row has the confidence and evidence: " + lines[1]); - Require(lines[2].EndsWith(",\"\",\"\""), "A device with no evidence leaves both columns empty."); + Require(lines[2].EndsWith(",\"\",\"\",\"\""), "A device with no evidence leaves its evidence columns empty."); string html = ReportExport.BuildHtml([shown], "192.0.2.0/24", "dark"); Require(html.Contains("title='" + Res("Str_Rec_Conf_High")) && html.Contains("<b>printer</b>") && !html.Contains("printer"), "The HTML type cell has an escaped evidence title."); diff --git a/tests/suites.json b/tests/suites.json index 11139bd2..d2a6c0c1 100644 --- a/tests/suites.json +++ b/tests/suites.json @@ -6,7 +6,7 @@ "ci": [ "--vendor-db", "--vendor-snapshot", "--subnet", "--fingerprint-cancellation", "--http-redirects", "--mac-cancellation", "--snmp", "--upload-accounting", "--scan-options", "--safe-probes", "--netbios", - "--api-contract", "--dns-budget", "--binding", "--recognition" + "--api-contract", "--dns-budget", "--binding", "--recognition", "--access" ], "excluded": {} }, @@ -20,7 +20,7 @@ "--scan-presentation", "--menu-separators", "--startup-fade", "--release-ui", "--scan-options-ui", "--history-ui", "--history-settings-ui", "--history-table-ui", "--profiles-ui", "--menu-icons-ui", "--topology", "--topology-stream", "--topology-keyboard", "--device-table", "--report-language", "--csv-export", "--export-publication", "--history-load", "--run-evidence", "--locations", - "--locations-sidebar", "--location-workspace", "--recognition-ui", "--report-sort", "--command-line-path", + "--locations-sidebar", "--location-workspace", "--recognition-ui", "--access-ui", "--report-sort", "--command-line-path", "--executable-deployment", "--report-logo", "--outline-hover-ui", "--radio-center-ui", "--shrink-to-fit" ], "excluded": { From d36df04e0c396b01316336df1db65dada7ec3745 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 14:40:21 -0700 Subject: [PATCH 110/333] v1.8.4: Accept the memory test's start line under a UTF-8 console Under code page 65001 the parent's .NET Framework stdin writer sends a UTF-8 byte order mark as soon as it is created, so the capped child read the mark plus START and refused to run. The child now reads its handshake as bytes and skips a leading mark, and its error names what it read and whether it is in the job. --- tests/SpeedTest.Tests/TerminalMemoryTests.cs | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/SpeedTest.Tests/TerminalMemoryTests.cs b/tests/SpeedTest.Tests/TerminalMemoryTests.cs index 50306a0a..ad93b38f 100644 --- a/tests/SpeedTest.Tests/TerminalMemoryTests.cs +++ b/tests/SpeedTest.Tests/TerminalMemoryTests.cs @@ -58,8 +58,12 @@ internal static void Run() internal static void Child(string preset) { - if (Console.ReadLine() != "START" || !IsProcessInJob(Process.GetCurrentProcess().Handle, IntPtr.Zero, out bool inJob) || !inJob) - throw new InvalidOperationException("Memory workload requires its parent job."); + string? handshake = ReadHandshake(); + bool queried = IsProcessInJob(Process.GetCurrentProcess().Handle, IntPtr.Zero, out bool inJob); + if (handshake != "START" || !queried || !inJob) + throw new InvalidOperationException("Memory workload requires its parent job. Read " + + (handshake == null ? "nothing" : "\"" + string.Join(" ", Array.ConvertAll(handshake.ToCharArray(), c => ((int)c).ToString("X4"))) + "\"") + + ", in job: " + (queried ? inJob.ToString() : "query failed") + "."); if (preset == "control20000x80") { Exception? failure = null; @@ -103,6 +107,21 @@ internal static void Child(string preset) GC.KeepAlive(buffer); } + /// + /// The first stdin line, read as bytes. When the console's code page is UTF-8 (65001), the + /// parent's .NET Framework stdin writer sends a byte order mark as soon as it is created, and + /// Console.ReadLine would hand that back as part of the line, so a leading mark is skipped. + /// + private static string? ReadHandshake() + { + using var input = Console.OpenStandardInput(); + var bytes = new System.Collections.Generic.List(); + for (int b; bytes.Count < 64 && (b = input.ReadByte()) >= 0 && b != '\n';) bytes.Add((byte)b); + if (bytes.Count == 0) return null; + int skip = bytes.Count >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF ? 3 : 0; + return Encoding.ASCII.GetString(bytes.ToArray(), skip, bytes.Count - skip).TrimEnd('\r'); + } + private static void Control() { var terminal = new TerminalControl(); From 4af91da76a1d395a365b62b5e839f906469fe024 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 15:05:56 -0700 Subject: [PATCH 111/333] v1.8.4: Follow gradient themes on the shortcuts card edge, add heading accent outline --- CHANGELOG.md | 2 +- Controls/AccentShadow.cs | 69 +++++++++++++++++++++++++++++++ Controls/HighContrastResources.cs | 3 +- Services/ThemeManager.cs | 29 +++++++++++++ Shell/MainWindow.xaml | 4 +- Themes/98SE.xaml | 2 +- Themes/Defaults.xaml | 5 ++- 7 files changed, 107 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e28839..a068343c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Release monitoring includes KillerScan.Engine on NuGet and reports missing or failed publication. - Chocolatey publishing reports failure when validation is still failing after a retry. - Window resize minimums stay within the current monitor's work area on small or scaled displays. -- The shortcuts card shows shadows at its edges when more content is out of view. +- The shortcuts card shows shadows at its edges when more content is out of view. Its border follows gradient themes, and its hint sits centered closer to the bottom edge. - Sidebar fades leave the scrollbar clear and disappear at the start and end of each list. - File pickers use theme content colors and app icons; 98SE dialogs have full bevels and compact fields. Text buttons, menus, tooltips and shortcuts use the embedded interface font, including separate windows. - Film grain has crisper specks and fades midtones across app and website themes without dimming bright highlights. diff --git a/Controls/AccentShadow.cs b/Controls/AccentShadow.cs index 5b126efe..81e3d37e 100644 --- a/Controls/AccentShadow.cs +++ b/Controls/AccentShadow.cs @@ -11,6 +11,75 @@ namespace KillerFamily.Theming { + /// Uses the logo color for headings with a thin, contrasting edge around the text. + public static class HeadingAccent + { + public static readonly DependencyProperty EnabledProperty = DependencyProperty.RegisterAttached( + "Enabled", typeof(bool), typeof(HeadingAccent), new PropertyMetadata(false, OnEnabledChanged)); + + public static bool GetEnabled(DependencyObject element) => (bool)element.GetValue(EnabledProperty); + public static void SetEnabled(DependencyObject element, bool value) => element.SetValue(EnabledProperty, value); + + private static readonly IValueConverter Converter = new HeadingOutlineConverter(); + + private static void OnEnabledChanged(DependencyObject element, DependencyPropertyChangedEventArgs args) + { + if (element is not TextBlock text) return; + text.Loaded -= RefreshOutline; + text.SizeChanged -= RefreshOutline; + if (!(bool)args.NewValue) + { + text.ClearValue(TextBlock.ForegroundProperty); + text.ClearValue(UIElement.EffectProperty); + return; + } + text.SetResourceReference(TextBlock.ForegroundProperty, "AccentLogo"); + text.SetBinding(UIElement.EffectProperty, new Binding(nameof(TextBlock.Foreground)) + { + RelativeSource = RelativeSource.Self, + Converter = Converter, + ConverterParameter = text, + }); + text.Loaded += RefreshOutline; + text.SizeChanged += RefreshOutline; + } + + private static void RefreshOutline(object sender, RoutedEventArgs args) => + ((TextBlock)sender).GetBindingExpression(UIElement.EffectProperty)?.UpdateTarget(); + + private sealed class HeadingOutlineConverter : IValueConverter + { + public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (SystemParameters.HighContrast || value is not SolidColorBrush brush) return null; + // A black or white edge always provides at least 4.5:1 against an opaque color. + // Keep the family Yellow outline when it also meets that threshold. + double luminance = Luminance(brush.Color); + double darkContrast = (luminance + 0.05) / 0.05; + double lightContrast = 1.05 / (luminance + 0.05); + Color rim = darkContrast >= lightContrast ? Colors.Black : Colors.White; + double gray = Luminance(AccentShadow.RimColor); + double grayContrast = (Math.Max(luminance, gray) + 0.05) / (Math.Min(luminance, gray) + 0.05); + if (AccentShadow.Applies(brush) && grayContrast >= 4.5) rim = AccentShadow.RimColor; + double scale = parameter is Visual visual ? VisualTreeHelper.GetDpi(visual).DpiScaleX : 1.0; + var effect = new AccentShadow.Outline(rim, Math.Max(1.0, scale)); + if (effect.CanFreeze) effect.Freeze(); + return effect; + } + + private static double Luminance(Color color) => + 0.2126 * Linear(color.R) + 0.7152 * Linear(color.G) + 0.0722 * Linear(color.B); + + private static double Linear(byte channel) + { + double value = channel / 255.0; + return value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; + } + } + /// /// Light with the Yellow accent keeps its accent yellow and draws a thin gray outline around every /// yellow text and glyph so it reads on the light surfaces. A theme turns it on by giving diff --git a/Controls/HighContrastResources.cs b/Controls/HighContrastResources.cs index 12ba6ca4..27b0d145 100644 --- a/Controls/HighContrastResources.cs +++ b/Controls/HighContrastResources.cs @@ -119,7 +119,8 @@ void Brushes(Brush brush, params string[] keys) "MenuTextBrush", "TextFooter", "FilenameBrush", "CaptionTextBrush", "DialogCaptionTextBrush", "OverlayCloseBrush", "CaptionGlyphBrush", "ComboTextBrush", "OutlineRestBrush", "OutlineBtnBrush", "RadioAccent", "RadioHoverFgBrush", "ScrollThumbBrush", "ScrollThumbHoverBrush", "AccentLogo", "HeaderLineBrush", - "CardBorderBrush", "PaneBorderBrush", "AppBorderBrush", "InputBorderBrush", "InputHoverBrush", + "CardBorderBrush", "PaneBorderBrush", "AppBorderBrush", "OverlayEdgeBrush", "OverlayRuleBrush", + "InputBorderBrush", "InputHoverBrush", "MenuBorderBrush", "MenuSeparatorBrush", "ButtonEdgeBrush", "WindowEdgeBrush", "DialogFrameBrush", "SurfaceButtonEdgeBrush", "KsCatFile", "KsCatTools", "KsCatEdit", "KsCatNav", "KsCatView", "KsCatSearch", "KsCatHelp", "KsCatOcr", "KsCatAppt", "KsCatTabs"); diff --git a/Services/ThemeManager.cs b/Services/ThemeManager.cs index 1ecbb727..0c2d6b55 100644 --- a/Services/ThemeManager.cs +++ b/Services/ThemeManager.cs @@ -370,6 +370,13 @@ private static void LoadDict(Theme theme, Accent? accentOverride = null) if (!newDict.Contains("FooterBackgroundBrush")) newDict["FooterBackgroundBrush"] = newDict["BackgroundBrush"]; newDict["OverlayWindowBrush"] = newDict["BackgroundBrush"]; + // Their 1px edges. A single border color over a gradient surface reads as one end's + // color stuck to the other end (Delirium's red border under its purple side), so a + // gradient surface gets a gradient edge: the same stops lifted toward white. A solid + // surface keeps the border it always had. + Brush? gradientEdge = newDict["BackgroundBrush"] is LinearGradientBrush surface ? LiftedEdge(surface) : null; + newDict["OverlayEdgeBrush"] = gradientEdge ?? newDict["AppBorderBrush"]; + newDict["OverlayRuleBrush"] = gradientEdge ?? newDict["PaneBorderBrush"]; if (!newDict.Contains("WordmarkAccentShadowOpacity")) newDict["WordmarkAccentShadowOpacity"] = 0.0; if (!hasExplicitComboHighlightText) newDict["ComboHighlightTextBrush"] = newDict["PrimaryBrush"]; @@ -394,5 +401,27 @@ private static void Publish(ResourceDictionary target) if (merged.Count > 0) merged[0] = target; else merged.Add(target); } + + /// + /// The surface gradient with every stop mixed one fifth of the way to white, which lands + /// near each gradient theme's own border tone at that end, as KillerShell's card edge does. + /// + internal static LinearGradientBrush LiftedEdge(LinearGradientBrush surface) + { + var edge = new LinearGradientBrush + { + StartPoint = surface.StartPoint, + EndPoint = surface.EndPoint, + MappingMode = surface.MappingMode, + SpreadMethod = surface.SpreadMethod, + ColorInterpolationMode = surface.ColorInterpolationMode, + }; + static byte Lift(byte channel) => (byte)(channel + (255 - channel) / 5); + foreach (var stop in surface.GradientStops) + edge.GradientStops.Add(new GradientStop( + Color.FromArgb(255, Lift(stop.Color.R), Lift(stop.Color.G), Lift(stop.Color.B)), stop.Offset)); + edge.Freeze(); + return edge; + } } } diff --git a/Shell/MainWindow.xaml b/Shell/MainWindow.xaml index 74139c5d..43da5eac 100644 --- a/Shell/MainWindow.xaml +++ b/Shell/MainWindow.xaml @@ -853,7 +853,7 @@ FlyoutCornerRadius with 0,0,4,4 - a dropdown shape that hangs off a button, so this centered card came out with square top corners. PanelCornerRadius is 6 on every flat theme and 0 on 98SE, which is the shape a card wants. --> - + FontSize="11" TextWrapping="Wrap" TextAlignment="Center" Margin="0,10,0,0"/> 18,30,18,18 24,30,24,20 0,0,-22,-18 - 0,0,22,18 + 0,0,22,10 18 16 0,4,4,0 diff --git a/Themes/Defaults.xaml b/Themes/Defaults.xaml index ae87d115..6126db23 100644 --- a/Themes/Defaults.xaml +++ b/Themes/Defaults.xaml @@ -120,9 +120,10 @@ 24,18,24,18 + itself back where the content margin had it, except the bottom, which is 8px tighter: + only the one-line hint sits there. --> 0,0,-24,-12 - 0,0,24,12 + 0,0,24,4 28 26 0,6,6,0 From 17a47d23d39f548c0b687b2ad83c7eb3cfc35688 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 15:32:41 -0700 Subject: [PATCH 112/333] v1.8.4: Report an unusable Locations catalog or import instead of crashing, and migrate once A catalog from a newer version or holding null threw InvalidDataException past the store's error handling and stopped the app at startup; it is now reported like a corrupt file. Imports refuse stores that are not JSON objects or lists, and open the staged copy before adding the Location. Migration holds a named lock so two windows migrate once, and sets a leftover unassigned folder aside instead of deleting it. Once the catalog exists, an unreadable one gives Temporary scans rather than writing to the stale pre-Locations files. --- Services/JsonPreferenceFile.cs | 10 +++- Services/LocationService.cs | 19 ++++++- Services/LocationTransfer.cs | 13 +++++ Services/Locations.cs | 42 +++++++++++++- tests/SpeedTest.Tests/LocationTests.cs | 77 ++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 7 deletions(-) diff --git a/Services/JsonPreferenceFile.cs b/Services/JsonPreferenceFile.cs index 93471393..78307aeb 100644 --- a/Services/JsonPreferenceFile.cs +++ b/Services/JsonPreferenceFile.cs @@ -25,10 +25,16 @@ internal JsonPreferenceFile(string path, Func create, Func normalize, A internal void Load() { try { Value = Read(); Error = null; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or ArgumentException) + catch (Exception ex) when (IsUnreadable(ex)) { Value = _create(); Error = ex.Message; } } + // InvalidDataException covers a file that parses but is not usable: a literal null, or a + // normalizer refusing it, such as a newer format version. It is reported like any other + // unreadable file instead of escaping to the caller and stopping the app. + private static bool IsUnreadable(Exception ex) => + ex is IOException or UnauthorizedAccessException or JsonException or ArgumentException or InvalidDataException; + internal bool Change(Action mutation, bool memoryOnly = false) { mutation(Value); @@ -58,7 +64,7 @@ internal bool Save() } finally { if (owned) mutex.ReleaseMutex(); } } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or ArgumentException) + catch (Exception ex) when (IsUnreadable(ex)) { Error = ex.Message; return false; } } diff --git a/Services/LocationService.cs b/Services/LocationService.cs index 381f4e7d..b7d64095 100644 --- a/Services/LocationService.cs +++ b/Services/LocationService.cs @@ -41,11 +41,24 @@ public static void Load(string unassignedName) } _catalog = new LocationCatalog(Root); _catalog.Load(unassignedName); - if (_catalog.LoadError != null) + // Another window replacing the catalog can make one read fail; a moment later it reads. + for (int retry = 0; retry < 3 && _catalog.LoadError != null && _catalog.Published; retry++) { - _fallback = LocationData.Shared(new LocationInfo { Id = LocationCatalog.UnassignedId, Name = unassignedName }); - DevicePreferences.Load(); DeviceOverrides.Load(); DeviceLogins.Load(); ScanProfiles.Load(); ScanHistory.Load(); + System.Threading.Thread.Sleep(150); + _catalog.Load(unassignedName); } + if (_catalog.LoadError == null) return; + if (_catalog.Published) + { + // After migration the app-wide files are a stale backup, so writing there would + // split a site's data in two. Until the catalog reads again, scans are Temporary: + // nothing is read or saved, and LoadError says why. + _fallback = LocationData.Temporary(); + return; + } + // Migration has not happened yet: the app-wide files are still the live data. + _fallback = LocationData.Shared(new LocationInfo { Id = LocationCatalog.UnassignedId, Name = unassignedName }); + DevicePreferences.Load(); DeviceOverrides.Load(); DeviceLogins.Load(); ScanProfiles.Load(); ScanHistory.Load(); } /// The data for a Location, shared by every tab that has it open. diff --git a/Services/LocationTransfer.cs b/Services/LocationTransfer.cs index af5546e8..656c2152 100644 --- a/Services/LocationTransfer.cs +++ b/Services/LocationTransfer.cs @@ -59,6 +59,13 @@ internal static bool Import(LocationCatalog catalog, byte[] bytes, out LocationI error = "This Location file contains something other than Location data."; return false; } + // Every saved file is a JSON object or list. Anything else, such as null, is refused + // here rather than becoming a Location that cannot be opened. + if (files.Values.Any(value => value.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array))) + { + error = "This Location file is damaged."; + return false; + } string staging = Path.Combine(catalog.LocationsDirectory, ".import-" + Guid.NewGuid().ToString("N")); try @@ -69,6 +76,12 @@ internal static bool Import(LocationCatalog catalog, byte[] bytes, out LocationI byte[] content = Encoding.UTF8.GetBytes(pair.Value.GetRawText()); JsonPreferenceFile.Publish(Path.Combine(staging, pair.Key.ToLowerInvariant()), content); } + // Open the staged copy the way the app will. A file the stores cannot read is + // refused before the catalog changes, so no unusable Location is ever listed. + string? unreadable; + try { unreadable = LocationData.Open(new LocationInfo { Id = "import", Name = file.Name! }, staging).ReadError; } + catch (Exception ex) { unreadable = ex.Message; } + if (unreadable != null) { error = "This Location file is damaged. " + unreadable; return false; } if (!catalog.Create(file.Name!, out var created) || created == null) { error = catalog.SaveError ?? catalog.LoadError ?? "The Location could not be added."; diff --git a/Services/Locations.cs b/Services/Locations.cs index 17a27c90..39f072dd 100644 --- a/Services/Locations.cs +++ b/Services/Locations.cs @@ -96,6 +96,9 @@ internal static LocationData Temporary() return data; } + /// The first store that could not be read when this Location was opened, if any. + internal string? ReadError => Devices.SaveError ?? Overrides.SaveError ?? Logins.SaveError ?? SavedScans.SaveError ?? History.LoadError; + private void Load() { Devices.Load(); @@ -149,7 +152,7 @@ public void Load(string unassignedName) LoadError = _store.Error; return; } - try { Migrate(unassignedName); } + try { MigrateOnce(unassignedName); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // The app keeps using the original files; the next start tries again. @@ -157,6 +160,31 @@ public void Load(string unassignedName) } } + // Two windows starting together would otherwise both migrate, and the second could + // delete the folder the first had just published. One named mutex per catalog path + // serializes them, and whoever waits finds the catalog already there and just loads it. + private void MigrateOnce(string unassignedName) + { + using var hash = System.Security.Cryptography.SHA256.Create(); + string key = BitConverter.ToString(hash.ComputeHash(System.Text.Encoding.UTF8.GetBytes(_path.ToUpperInvariant()))).Replace("-", ""); + using var mutex = new System.Threading.Mutex(false, "Global\\KillerScan.Migrate." + key); + bool owned = false; + try + { + try { owned = mutex.WaitOne(TimeSpan.FromSeconds(30)); } + catch (System.Threading.AbandonedMutexException) { owned = true; } + if (!owned) throw new IOException("Another KillerScan window is still moving your data into Locations. Restart KillerScan to try again."); + if (File.Exists(_path)) + { + _store.Load(); + LoadError = _store.Error; + return; + } + Migrate(unassignedName); + } + finally { if (owned) mutex.ReleaseMutex(); } + } + // Copy into a staging folder, verify every byte, move it into place, then publish the // catalog last. Any step can fail or be interrupted: until the catalog exists, the next // start repeats the whole copy from the untouched originals. @@ -177,7 +205,14 @@ private void Migrate(string unassignedName) if (!File.ReadAllBytes(source).SequenceEqual(File.ReadAllBytes(copy))) throw new IOException("The copy of " + name + " did not match the original."); } - if (Directory.Exists(target)) Directory.Delete(target, true); + // A folder already here is left by a migration interrupted after the move but + // before the catalog. It is set aside, never deleted, in case anything wrote to it. + if (Directory.Exists(target)) + { + string deleted = Path.Combine(locations, ".deleted"); + Directory.CreateDirectory(deleted); + Directory.Move(target, Path.Combine(deleted, UnassignedId + "-" + DateTime.Now.ToString("yyyyMMdd-HHmmss-fff"))); + } Directory.Move(staging, target); } if (!_store.Change(value => @@ -187,6 +222,9 @@ private void Migrate(string unassignedName) })) throw new IOException(_store.Error ?? "The Locations catalog could not be saved."); } + /// True once migration has published the catalog, even if it cannot be read now. + internal bool Published => File.Exists(_path); + internal string LocationsDirectory => Path.Combine(_root, "locations"); internal string DirectoryFor(LocationInfo info) => Path.Combine(LocationsDirectory, info.Id); diff --git a/tests/SpeedTest.Tests/LocationTests.cs b/tests/SpeedTest.Tests/LocationTests.cs index 8a6abd2e..a2851890 100644 --- a/tests/SpeedTest.Tests/LocationTests.cs +++ b/tests/SpeedTest.Tests/LocationTests.cs @@ -19,6 +19,10 @@ internal static void Run() Migration(Path.Combine(root, "migrate")); InterruptedMigration(Path.Combine(root, "interrupted")); CorruptCatalog(Path.Combine(root, "corrupt")); + UnusableCatalog(Path.Combine(root, "unusable")); + UnreadableAfterMigration(Path.Combine(root, "stale")); + ConcurrentMigration(Path.Combine(root, "concurrent")); + LeftoverUnassigned(Path.Combine(root, "leftover")); Isolation(Path.Combine(root, "isolation")); KnownAndApproved(Path.Combine(root, "known")); CommandLine(Path.Combine(root, "cli")); @@ -110,6 +114,76 @@ private static void CorruptCatalog(string root) Require(!catalog.Create("New site", out _), "Nothing is added to a catalog that could not be read."); } + // A catalog that parses but cannot be used, from a newer version or holding a literal null, + // is reported like a corrupt one. It used to escape as an exception and stop the app. + private static void UnusableCatalog(string root) + { + foreach (string content in new[] { "{\"Version\":2,\"Locations\":[]}", "null" }) + { + Directory.CreateDirectory(root); + File.WriteAllText(Path.Combine(root, "locations.json"), content); + var catalog = new LocationCatalog(root); + catalog.Load("Unassigned"); + Require(catalog.LoadError != null && catalog.Items.Count == 0 && !catalog.Create("New site", out _), + "An unusable catalog is reported and changes nothing: " + content); + Require(File.ReadAllText(Path.Combine(root, "locations.json")) == content, "The unusable catalog keeps its bytes: " + content); + } + } + + // After migration the app-wide files are a stale backup. When the catalog cannot be read, + // the app runs Temporary scans rather than writing to them. + private static void UnreadableAfterMigration(string root) + { + SeedLegacy(root); + var originals = StoreFiles.ToDictionary(name => name, name => File.ReadAllBytes(Path.Combine(root, name))); + File.WriteAllText(Path.Combine(root, "locations.json"), "{broken"); + string previous = LocationService.Root; + try + { + LocationService.Root = root; + LocationService.Load("Unassigned"); + var data = LocationService.Get(new LocationInfo { Id = LocationCatalog.UnassignedId, Name = "Unassigned" }); + Require(LocationService.LoadError != null && !LocationService.Editable && LocationService.Items.Count == 0 && data.IsTemporary, + "An unreadable catalog after migration gives Temporary scans and reports why."); + data.Devices.SetName(Device("192.168.1.10"), "Typed while broken"); + data.History.Record("192.168.1.0/24", [Device("192.168.1.10")]); + foreach (var pair in originals) + Require(File.ReadAllBytes(Path.Combine(root, pair.Key)).SequenceEqual(pair.Value), "The stale " + pair.Key + " is never written."); + } + finally { LocationService.Root = previous; } + } + + // Two windows starting together migrate once, and the data they both see is intact. + private static void ConcurrentMigration(string root) + { + SeedLegacy(root); + var catalogs = new[] { new LocationCatalog(root), new LocationCatalog(root) }; + var threads = catalogs.Select(catalog => new System.Threading.Thread(() => catalog.Load("Unassigned"))).ToList(); + threads.ForEach(thread => thread.Start()); + threads.ForEach(thread => thread.Join()); + foreach (var catalog in catalogs) + Require(catalog.LoadError == null && catalog.Items.Count == 1 && catalog.Items[0].Id == LocationCatalog.UnassignedId, + "Both windows see one Unassigned Location: " + catalog.LoadError); + Require(catalogs[0].Open(catalogs[0].Items[0]).Devices.GetName(Device("192.168.1.10")) == "Front desk PC", + "The migrated data survives two windows starting together."); + Require(!Directory.Exists(Path.Combine(root, "locations", ".deleted")), "A single migration sets nothing aside."); + } + + // A folder left by a migration that stopped before the catalog is set aside, never deleted. + private static void LeftoverUnassigned(string root) + { + SeedLegacy(root); + string leftover = Path.Combine(root, "locations", LocationCatalog.UnassignedId); + Directory.CreateDirectory(leftover); + File.WriteAllText(Path.Combine(leftover, "marker.txt"), "written after the earlier move"); + var catalog = new LocationCatalog(root); + catalog.Load("Unassigned"); + Require(catalog.LoadError == null && catalog.Open(catalog.Items.Single()).Devices.GetName(Device("192.168.1.10")) == "Front desk PC", + "Migration still completes over a leftover folder."); + var kept = Directory.GetDirectories(Path.Combine(root, "locations", ".deleted"), LocationCatalog.UnassignedId + "-*").Single(); + Require(File.ReadAllText(Path.Combine(kept, "marker.txt")) == "written after the earlier move", "The leftover folder is kept under .deleted."); + } + private static void Isolation(string root) { var catalog = new LocationCatalog(root); @@ -283,6 +357,9 @@ private static void Transfer(string root) ("wrong format", "{\"Format\":\"Something.Else\",\"Version\":1,\"Name\":\"X\",\"Files\":{}}"), ("unexpected file", "{\"Format\":\"KillerScan.Location\",\"Version\":1,\"Name\":\"X\",\"Files\":{\"..\\\\evil.json\":{}}}"), ("malformed", "{broken"), + ("null store", "{\"Format\":\"KillerScan.Location\",\"Version\":1,\"Name\":\"X\",\"Files\":{\"devices.json\":null}}"), + ("number store", "{\"Format\":\"KillerScan.Location\",\"Version\":1,\"Name\":\"X\",\"Files\":{\"profiles.json\":5}}"), + ("unreadable store", "{\"Format\":\"KillerScan.Location\",\"Version\":1,\"Name\":\"X\",\"Files\":{\"history.json\":{\"Entries\":5}}}"), }) { int before = catalog.Items.Count; From 88b77be1368461191e068d0ec1fd535088397d48 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 15:32:42 -0700 Subject: [PATCH 113/333] v1.8.4: Send bound pings asynchronously so silent hosts hold no threads Bound pings ran the blocking IcmpSendEcho2Ex inside Task.Run, so each silent host held a worker thread for its whole timeout and a bound sweep starved the pool. They now use the API's event mode with a registered wait. 200 silent bound pings were all in flight at once with no worker threads busy. --- Engine/Network/ScanSource.cs | 86 ++++++++++++++----- tests/KillerScan.Engine.Tests/BindingTests.cs | 8 ++ 2 files changed, 71 insertions(+), 23 deletions(-) diff --git a/Engine/Network/ScanSource.cs b/Engine/Network/ScanSource.cs index d46d0201..622f6892 100644 --- a/Engine/Network/ScanSource.cs +++ b/Engine/Network/ScanSource.cs @@ -40,8 +40,7 @@ internal static UdpClient Udp() => var reply = await ping.SendPingAsync(address, timeoutMs); return (reply.Status == IPStatus.Success, reply.Options?.Ttl ?? 0); } - var source = binding.LocalAddress; - return await Task.Run(() => BoundEcho(source, address, timeoutMs)); + return await BoundEchoAsync(binding.LocalAddress, address, timeoutMs); } /// The MAC for an address; bound runs ask from the adapter's address. @@ -66,35 +65,76 @@ internal static string ResolveMac(IPAddress address) [DllImport("iphlpapi.dll", SetLastError = true)] private static extern uint IcmpSendEcho2Ex(IntPtr handle, IntPtr evt, IntPtr apcRoutine, IntPtr apcContext, - uint sourceAddress, uint destinationAddress, byte[] requestData, ushort requestSize, + uint sourceAddress, uint destinationAddress, IntPtr requestData, ushort requestSize, IntPtr requestOptions, IntPtr replyBuffer, uint replySize, uint timeout); - // System.Net.NetworkInformation.Ping cannot choose a source address; IcmpSendEcho2Ex can. - private static (bool Success, int Ttl) BoundEcho(IPAddress source, IPAddress destination, int timeoutMs) + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern uint IcmpParseReplies(IntPtr replyBuffer, uint replySize); + + private const int ErrorIoPending = 997; + private const int PayloadSize = 32; + private const int ReplySize = 64 + PayloadSize + 8 + 64; + + /// + /// System.Net.NetworkInformation.Ping cannot choose a source address; IcmpSendEcho2Ex can. + /// It runs in its asynchronous mode: Windows signals an event when the reply or the + /// timeout arrives, and a registered wait completes the task, so a bound sweep of silent + /// hosts holds no thread while it waits. The request and reply buffers are unmanaged and + /// stay alive until that signal. + /// + private static Task<(bool Success, int Ttl)> BoundEchoAsync(IPAddress source, IPAddress destination, int timeoutMs) { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return (false, 0); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return Task.FromResult((false, 0)); IntPtr handle = IcmpCreateFile(); - if (handle == new IntPtr(-1) || handle == IntPtr.Zero) return (false, 0); - byte[] payload = new byte[32]; - int replySize = 64 + payload.Length + 8 + 64; - IntPtr reply = Marshal.AllocHGlobal(replySize); - try + if (handle == new IntPtr(-1) || handle == IntPtr.Zero) return Task.FromResult((false, 0)); + var signal = new ManualResetEvent(false); + IntPtr payload = Marshal.AllocHGlobal(PayloadSize); + IntPtr reply = Marshal.AllocHGlobal(ReplySize); + for (int i = 0; i < PayloadSize; i++) Marshal.WriteByte(payload, i, 0); + var done = new TaskCompletionSource<(bool, int)>(TaskCreationOptions.RunContinuationsAsynchronously); + + void Release(bool freeBuffers) { - uint count = IcmpSendEcho2Ex(handle, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, - BitConverter.ToUInt32(source.GetAddressBytes(), 0), BitConverter.ToUInt32(destination.GetAddressBytes(), 0), - payload, (ushort)payload.Length, IntPtr.Zero, reply, (uint)replySize, (uint)timeoutMs); - if (count == 0) return (false, 0); - // ICMP_ECHO_REPLY: Address, Status, RoundTripTime (4 bytes each), DataSize and - // Reserved (2 each), a Data pointer, then IP_OPTION_INFORMATION starting with Ttl. - uint status = (uint)Marshal.ReadInt32(reply, 4); - int ttl = Marshal.ReadByte(reply, 16 + IntPtr.Size); - return (status == 0, ttl); + IcmpCloseHandle(handle); + signal.Dispose(); + if (!freeBuffers) return; + Marshal.FreeHGlobal(payload); + Marshal.FreeHGlobal(reply); } - finally + + uint sent = IcmpSendEcho2Ex(handle, signal.SafeWaitHandle.DangerousGetHandle(), IntPtr.Zero, IntPtr.Zero, + BitConverter.ToUInt32(source.GetAddressBytes(), 0), BitConverter.ToUInt32(destination.GetAddressBytes(), 0), + payload, PayloadSize, IntPtr.Zero, reply, ReplySize, (uint)timeoutMs); + if (sent == 0 && Marshal.GetLastWin32Error() != ErrorIoPending) { - Marshal.FreeHGlobal(reply); - IcmpCloseHandle(handle); + Release(freeBuffers: true); + return Task.FromResult((false, 0)); } + + RegisteredWaitHandle? registration = null; + registration = ThreadPool.RegisterWaitForSingleObject(signal, (_, timedOut) => + { + registration?.Unregister(null); + if (timedOut) + { + // Windows has not finished with the buffers, so they are left allocated + // rather than freed under it. ICMP's own timeout makes this all but unreachable. + Release(freeBuffers: false); + done.TrySetResult((false, 0)); + return; + } + (bool, int) result = (false, 0); + if (IcmpParseReplies(reply, ReplySize) > 0) + { + // ICMP_ECHO_REPLY: Address, Status, RoundTripTime (4 bytes each), DataSize and + // Reserved (2 each), a Data pointer, then IP_OPTION_INFORMATION starting with Ttl. + uint status = (uint)Marshal.ReadInt32(reply, 4); + result = (status == 0, Marshal.ReadByte(reply, 16 + IntPtr.Size)); + } + Release(freeBuffers: true); + done.TrySetResult(result); + }, null, timeoutMs + 5000, executeOnlyOnce: true); + return done.Task; } } } diff --git a/tests/KillerScan.Engine.Tests/BindingTests.cs b/tests/KillerScan.Engine.Tests/BindingTests.cs index a49d2e2a..c1e3e20a 100644 --- a/tests/KillerScan.Engine.Tests/BindingTests.cs +++ b/tests/KillerScan.Engine.Tests/BindingTests.cs @@ -63,6 +63,14 @@ await RequireRefused(() => scanner.ScanSubnetAsync("198.51.100.20,203.0.113.20", var pinger = new NetworkScanner(new IsolatedDiscovery()) { Binding = new ScanBinding("Loopback", IPAddress.Loopback, 8) }; var pinged = await pinger.DeepProbeHostAsync("127.0.0.1", CancellationToken.None, options.Without("ports,http")); Require(pinged.Ttl > 0, "A bound ping from the adapter's address gets a reply and a TTL."); + // Bound pings run asynchronously, so many at once all complete with their own reply. + var burst = await Task.WhenAll(Enumerable.Range(0, 64).Select(_ => Task.Run(async () => + { + ScanSource.Current = new ScanBinding("Loopback", IPAddress.Loopback, 8); + try { return await ScanSource.PingAsync(IPAddress.Loopback, 1000); } + finally { ScanSource.Current = null; } + }))); + Require(burst.All(reply => reply.Success && reply.Ttl > 0), "64 concurrent bound pings each get a reply and a TTL."); var unbound = new NetworkScanner(new IsolatedDiscovery()); int before = tcp.Sources.Count; From 32952826a58f42af54f779a23b513b252fade4da Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 15:32:43 -0700 Subject: [PATCH 114/333] v1.8.4: Return recognition and access evidence from the PowerShell module ConvertTo-KsDevice dropped Recognition and Access. Both are now on each device, with Access always an array, and the module contract checks them on PowerShell 5.1 and 7. --- Modules/KillerScan/KillerScan.psm1 | 4 ++++ tests/Module/ModuleContract.ps1 | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/Modules/KillerScan/KillerScan.psm1 b/Modules/KillerScan/KillerScan.psm1 index 288c9c1f..6de33380 100644 --- a/Modules/KillerScan/KillerScan.psm1 +++ b/Modules/KillerScan/KillerScan.psm1 @@ -97,6 +97,10 @@ function ConvertTo-KsDevice { Netbios = $Device.NetbiosName Mdns = @($Device.MdnsServices) Ssdp = $Device.SsdpServer + # Why the device has its type (signals, alternatives, confidence), and each endpoint + # with what it answered. Access is an empty array, not $null, when none was recorded. + Recognition = $Device.Recognition + Access = [object[]]@($Device.Access | Where-Object { $null -ne $_ }) } $out.PSObject.TypeNames.Insert(0, 'KillerScan.Device') $out diff --git a/tests/Module/ModuleContract.ps1 b/tests/Module/ModuleContract.ps1 index ab0a0e40..368c66e5 100644 --- a/tests/Module/ModuleContract.ps1 +++ b/tests/Module/ModuleContract.ps1 @@ -47,6 +47,13 @@ try { Require ($devices.Count -gt 0) 'scan -Demo returns device objects from the real executable.' Require ($devices[0].PSObject.TypeNames[0] -eq 'KillerScan.Device') 'Devices carry the KillerScan.Device type.' Require (-not [string]::IsNullOrEmpty($devices[0].Ip)) 'Devices carry their IP address.' + Require (-not [string]::IsNullOrEmpty($devices[0].Recognition.Confidence)) 'Devices carry their recognition evidence.' + + $env:KS_EXE = New-FakeExe 'evidence' '[{"IpAddress":"192.0.2.9","OpenPorts":[443],"Recognition":{"Type":"Printer","Confidence":"High"},"Access":[{"Protocol":"https","Port":443,"Evidence":"HttpResponse","HttpStatus":401,"Login":"Requested"}]},{"IpAddress":"192.0.2.10","OpenPorts":[]}]' '' 0 + $evidence = @(scan 192.0.2.0/30) + Require ($evidence[0].Recognition.Confidence -eq 'High' -and $evidence[0].Access.Count -eq 1 -and $evidence[0].Access[0].Login -eq 'Requested') 'Devices carry their access endpoints.' + Require ($evidence[1].Access.Count -eq 0 -and $null -eq $evidence[1].Recognition) 'A device without evidence has an empty Access list and no Recognition.' + $env:KS_EXE = $realExe $usage = Get-Failure { probe 'not-an-address' } Require ($null -ne $usage) 'A usage error from the real executable is a terminating error.' From 3e94d4767635f2fb2d94975665713e68404ec109 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 15:32:44 -0700 Subject: [PATCH 115/333] v1.8.4: Clear results when changing Location and save the chosen adapter Results left on screen after a Location switch had their names and approvals written into the new Location. Changing Location now clears them, leaving unsaved Temporary results asks first, and picking the current Location changes nothing. Save and the sidebar Save now record the adapter chosen at the time instead of keeping the old one. --- CHANGELOG.md | 6 +- Controls/ScanWorkspace/Location.cs | 56 +++++++++++++++++-- Services/ScanProfiles.cs | 12 ++-- Shell/Profiles.cs | 2 +- Strings/bn.xaml | 3 + Strings/cs-CZ.xaml | 3 + Strings/de-DE.xaml | 3 + Strings/en-US.xaml | 3 + Strings/es.xaml | 3 + Strings/fr-FR.xaml | 3 + Strings/hu-HU.xaml | 3 + Strings/it-IT.xaml | 3 + Strings/ja-JP.xaml | 3 + Strings/kk-KZ.xaml | 3 + Strings/nb-NO.xaml | 3 + Strings/pl-PL.xaml | 3 + Strings/pt-BR.xaml | 3 + Strings/ru-RU.xaml | 3 + Strings/sl-SI.xaml | 3 + Strings/tr-TR.xaml | 3 + Strings/uk-UA.xaml | 3 + Strings/vi-VN.xaml | 3 + Strings/zh-CN.xaml | 3 + Strings/zh-TW.xaml | 3 + docs/locations.md | 4 ++ .../SpeedTest.Tests/LocationWorkspaceTests.cs | 41 ++++++++++++++ 26 files changed, 167 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a068343c..d63c400b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Added Slovenian across the app, installation prompts, website and help. (#5) (Thanks @MrCodeLabs) - Scan settings can let deep scans fingerprint unknown open ports. It is off by default, so unknown ports get a connection check only. - The PowerShell `scan` command takes `-Demo` for fabricated screenshot data. -- Locations keep each customer site's saved scans, device names, approvals, type overrides, SSH usernames and history separate. Pick one from the Location button beside the target box, or run a Temporary scan that saves nothing. On first start, everything you already had moves into a Location named Unassigned, and the original files are left in place. +- Locations keep each customer site's saved scans, device names, approvals, type overrides, SSH usernames and history separate. Pick one from the Location button beside the target box, or run a Temporary scan that saves nothing. Changing Location clears the results on screen, so a rename or approval never lands in the wrong site, and leaving unsaved Temporary results asks first. On first start, everything you already had moves into a Location named Unassigned, and the original files are left in place. - The first scan in a Location sets a baseline of Known devices without approving any. Later scans flag devices that are new since that baseline, and the device mark shows approved, new and known. - Saved scans belong to a Location and can be saved in place or under a new name. Picking a Location restores its last targets and settings without scanning. - Each Location has notes for the gateway, VLANs, quirks or who to call, shown in the Location button's tooltip. @@ -80,9 +80,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - `/scan` and `/probe` take `/skip `, and the PowerShell `scan` and `probe` commands take `-Skip`, to turn off the same checks Scan settings offers. The terminal's Scan button uses the current Scan settings. - `/scan` and `/probe` take `/adapter `, and the PowerShell commands take `-Adapter`, to send every probe from one adapter. An unknown adapter, or a target that is neither on its network nor routed through it, is a usage error, and the terminal's Scan button uses the adapter chosen in Scan settings. -- Each device type now explains itself. Hover over the type for its confidence (Low, Medium, High or Set manually) and its strongest evidence, or right-click and choose Why This Type? (Ctrl+I) for every signal, its source, close alternatives and any conflict. Opening it sends nothing to the device. A manual type keeps the automatic result alongside, HTML reports show the same summary on hover, and CLI JSON carries the full evidence. +- Each device type now explains itself. Hover over the type for its confidence (Low, Medium, High or Set manually) and its strongest evidence, or right-click and choose Why This Type? (Ctrl+I) for every signal, its source, close alternatives and any conflict. Opening it sends nothing to the device. A manual type keeps the automatic result alongside, HTML reports show the same summary on hover, and CLI JSON and the PowerShell module carry the full evidence. - Device CSV exports add Confidence and Evidence columns after the existing six. -- Right-click a device and choose Access to see every way into it: each web port and what it answered (a status, a login request or a sign-in page), the SSH banner, and every other open port. Clicking an entry opens it in the browser, SSH or Remote Desktop on that port, and Copy Address copies any of them. Nothing is claimed beyond what was seen, no credentials are ever sent, and opening the menu sends nothing. Deep scans now check every web port so each has its own evidence. CSV adds an Access column, HTML reports list the endpoints when hovering a device's ports, and CLI JSON carries the full list. +- Right-click a device and choose Access to see every way into it: each web port and what it answered (a status, a login request or a sign-in page), the SSH banner, and every other open port. Clicking an entry opens it in the browser, SSH or Remote Desktop on that port, and Copy Address copies any of them. Nothing is claimed beyond what was seen, no credentials are ever sent, and opening the menu sends nothing. Deep scans now check every web port so each has its own evidence. CSV adds an Access column, HTML reports list the endpoints when hovering a device's ports, and CLI JSON and the PowerShell module carry the full list. ### Changed - Open in Browser opens the web port that actually answered, HTTPS first, instead of always `http://` on port 80. The Services view opens a port with the scheme it answered with, including on non-standard ports. diff --git a/Controls/ScanWorkspace/Location.cs b/Controls/ScanWorkspace/Location.cs index a225f212..7529b0f1 100644 --- a/Controls/ScanWorkspace/Location.cs +++ b/Controls/ScanWorkspace/Location.cs @@ -57,9 +57,23 @@ private void ApplyLocationHooks() /// /// Moves this tab to another Location. Refused while scanning, because the run captured its /// Location when it started. Picking a Location sends no traffic: it restores that - /// Location's last targets and settings and leaves the current results on screen. + /// Location's last targets and settings. The results on screen are cleared, because + /// renaming or approving a row would otherwise write into the new Location. A completed + /// run is in its own Location's history; a Temporary scan's results are nowhere else, + /// so leaving them asks first. Picking the Location the tab is already in changes nothing. /// - internal bool SetLocation(LocationData data) + internal bool SetLocation(LocationData data) => SetLocation(data, keepResults: false); + + /// Asks whether to discard a Temporary scan's results. Tests answer it without a dialog. + internal Func? ConfirmDiscard { get; set; } + + private bool IsCurrentLocation(LocationData data) => + ReferenceEquals(data, _location) + || (data.Info != null && _location.Info != null && data.Info.Id == _location.Info.Id) + || (data.IsTemporary && _location.IsTemporary) + || (data.IsShared && _location.IsShared && data.Info == null && _location.Info == null); + + private bool SetLocation(LocationData data, bool keepResults) { if (_disposed) return false; if (IsScanning) @@ -67,6 +81,23 @@ internal bool SetLocation(LocationData data) StatusText.Text = Loc("Str_Loc_Busy"); return false; } + if (IsCurrentLocation(data)) return true; + bool clear = !keepResults && _active.Devices.Count > 0; + if (clear && _location.IsTemporary) + { + string message = Loc("Str_Loc_DiscardTemporary"); + bool discard; + if (ConfirmDiscard != null) discard = ConfirmDiscard(message); + else + { + var confirm = new ConfirmDialog(Loc("Str_Loc_DiscardTitle"), message, Loc("Str_Loc_DiscardConfirm"), Loc("Str_Btn_Cancel")) + { Owner = Window.GetWindow(this) }; + confirm.ShowDialog(); + discard = confirm.Confirmed; + } + if (!discard) return false; + } + if (clear) ClearResults(); _location = data; _loadedScanName = null; _active.NewIdentities.Clear(); @@ -84,6 +115,22 @@ internal bool SetLocation(LocationData data) return true; } + // Back to an empty tab, as if nothing had been scanned yet. + private void ClearResults() + { + _active.Devices.Clear(); + _active.NewIdentities.Clear(); + _active.ScannedSubnet = string.Empty; + _active.Completion = ScanCompletion.Partial; + _active.Refresh = null; + _active.Adapter = string.Empty; + _topologyPositions.Clear(); + _scanCompleted = false; + RefreshDeviceCount(); + UpdateDeepScanButton(); + UpdateExportButtonAvailability(); + } + /// The Location's current display name, after any rename. internal string LocationName => _location.Info is { } info ? LocationService.Find(info.Id)?.Name ?? info.Name @@ -473,7 +520,8 @@ internal bool SaveResultsTo(LocationData target) if (source.Devices.GetName(device) is { } name) saved &= target.Devices.SetName(device, name); PersistenceFeedback.Check(saved, () => target.History.Retry() && target.Devices.Retry(), () => target.History.SaveError ?? target.Devices.SaveError); - SetLocation(target); + // The results now belong to the target, so they stay on screen there. + SetLocation(target, keepResults: true); HistoryRecorded?.Invoke(this, EventArgs.Empty); return true; } @@ -490,7 +538,7 @@ private void SaveScan() { if (_loadedScanName == null) { SaveScanAs(); return; } var store = _location.SavedScans; - PersistenceFeedback.Check(store.SaveEdited(_loadedScanName, Targets.Trim(), _active.Options), store.Retry, () => store.SaveError); + PersistenceFeedback.Check(store.SaveEdited(_loadedScanName, Targets.Trim(), _active.Options, _adapterChoice), store.Retry, () => store.SaveError); LocationChanged?.Invoke(this, EventArgs.Empty); } diff --git a/Services/ScanProfiles.cs b/Services/ScanProfiles.cs index 5d990e9f..c272db2e 100644 --- a/Services/ScanProfiles.cs +++ b/Services/ScanProfiles.cs @@ -72,11 +72,11 @@ public bool Save(ScanProfile profile) } /// - /// The Save profile dialog edits only the target and options. Saving over an existing - /// name (any case) keeps the fields the dialog does not show, such as Deep Scan, and the - /// adapter unless a new one is given. + /// The Save profile dialog edits the target and options, and the adapter comes from the + /// tab's current choice (empty is Automatic), so it is required. Saving over an existing + /// name (any case) keeps only what the dialog does not show, such as Deep Scan. /// - public bool SaveEdited(string name, string target, ScanOptions options, string? adapter = null) => + public bool SaveEdited(string name, string target, ScanOptions options, string adapter) => _store.Change(items => { var existing = items.FirstOrDefault(item => string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)); @@ -85,7 +85,7 @@ public bool SaveEdited(string name, string target, ScanOptions options, string? { Name = name, Target = target, Options = options ?? new ScanOptions(), DeepScanAfter = existing?.DeepScanAfter ?? false, - Adapter = adapter ?? existing?.Adapter ?? string.Empty + Adapter = adapter ?? string.Empty }); }, MemoryOnly); @@ -108,7 +108,7 @@ internal static class ScanProfiles public static IReadOnlyList Items => Store.Items; public static void Load() { Store = new(FilePath); Store.Load(); } public static bool Save(ScanProfile profile) => Store.Save(profile); - public static bool SaveEdited(string name, string target, ScanOptions options) => Store.SaveEdited(name, target, options); + public static bool SaveEdited(string name, string target, ScanOptions options, string adapter = "") => Store.SaveEdited(name, target, options, adapter); public static bool Delete(ScanProfile profile) => Store.Delete(profile); public static bool Update(ScanProfile profile) => Store.Update(profile); public static bool Retry() => Store.Retry(); diff --git a/Shell/Profiles.cs b/Shell/Profiles.cs index d72bd273..27969262 100644 --- a/Shell/Profiles.cs +++ b/Shell/Profiles.cs @@ -45,7 +45,7 @@ private void SaveProfile_Click(object sender, RoutedEventArgs e) dialog.ShowDialog(); if (!dialog.Confirmed || string.IsNullOrWhiteSpace(dialog.Value)) return; var store = ShellLocation.SavedScans; - PersistenceFeedback.Check(store.SaveEdited(dialog.Value, scan.Targets.Trim(), scan.Options), + PersistenceFeedback.Check(store.SaveEdited(dialog.Value, scan.Targets.Trim(), scan.Options, scan.AdapterChoice), store.Retry, () => store.SaveError); RefreshProfilesList(); } diff --git a/Strings/bn.xaml b/Strings/bn.xaml index 0a87fd50..6a2a45fb 100644 --- a/Strings/bn.xaml +++ b/Strings/bn.xaml @@ -416,6 +416,9 @@ {0} মুছবেন? এর সংরক্ষিত স্ক্যান, ডিভাইসের নাম, অনুমোদন ও ইতিহাস KillerScan থেকে সরিয়ে দেওয়া হবে। ফাইলগুলো লোকেশনের "deleted" ফোল্ডারে রাখা থাকে, তাই সেগুলো পুনরুদ্ধার করা যাবে। মুছুন লোকেশন পরিবর্তনের আগে স্ক্যান বন্ধ করুন। + এই ফলাফলগুলো বাতিল করবেন? + এই “অস্থায়ী স্ক্যান” থেকে পাওয়া ফলাফল কোথাও সংরক্ষিত নেই। লোকেশন পরিবর্তন করলে সেগুলো মুছে যাবে। রেখে দিতে চাইলে এর বদলে “ফলাফল লোকেশনে সংরক্ষণ করুন” বেছে নিন। + ফেলে দিন লোকেশন সেট আপ করা যায়নি, তাই KillerScan আগের মতোই আপনার বিদ্যমান ডিভাইস, সংরক্ষিত স্ক্যান ও ইতিহাস ব্যবহার করছে। {0} কিছুই পরিবর্তন বা সরানো হয়নি। পরের বার চালু হলে KillerScan আবার চেষ্টা করবে। লোকেশনের নোট... নোট (Ctrl+Enter চাপলে সংরক্ষণ হয়) diff --git a/Strings/cs-CZ.xaml b/Strings/cs-CZ.xaml index a0caa992..2075be60 100644 --- a/Strings/cs-CZ.xaml +++ b/Strings/cs-CZ.xaml @@ -414,6 +414,9 @@ Vaše nastavení nebude odstraněno. Odstranit {0}? Její uložené skeny, názvy zařízení, schválení a historie budou z KillerScan odebrány. Soubory zůstanou ve složce "deleted" Lokalit, takže je lze obnovit. Odstranit Před změnou Lokality zastavte skenování. + Zahodit tyto výsledky? + Výsledky z režimu „Dočasný sken“ nejsou nikde uloženy. Změnou Lokality se vymažou. Chcete-li je zachovat, zvolte místo toho „Uložit výsledky do Lokality“. + Zahodit Lokality se nepodařilo nastavit, proto KillerScan používá vaše stávající zařízení, uložené skeny a historii jako dosud. {0} Nic nebylo změněno ani odstraněno. KillerScan to zkusí znovu při příštím spuštění. Poznámky k Lokalitě... Poznámky (Ctrl+Enter uloží) diff --git a/Strings/de-DE.xaml b/Strings/de-DE.xaml index 4e04fe89..842db1b4 100644 --- a/Strings/de-DE.xaml +++ b/Strings/de-DE.xaml @@ -417,6 +417,9 @@ Ihre Einstellungen bleiben erhalten. {0} löschen? Die gespeicherten Scans, Gerätenamen, Freigaben und der Verlauf dieses Standorts werden aus KillerScan entfernt. Die Dateien bleiben im Ordner "deleted" der Standorte erhalten und können wiederhergestellt werden. Löschen Stoppen Sie den Scan, bevor Sie den Standort wechseln. + Diese Ergebnisse verwerfen? + Diese Ergebnisse aus „Temporärer Scan“ werden nirgends gespeichert. Ein Wechsel des Standorts löscht sie. Um sie zu behalten, wählen Sie stattdessen „Ergebnisse im Standort speichern“. + Verwerfen Standorte konnten nicht eingerichtet werden, daher verwendet KillerScan Ihre vorhandenen Geräte, gespeicherten Scans und den Verlauf wie bisher. {0} Es wurde nichts geändert oder entfernt. KillerScan versucht es beim nächsten Start erneut. Standortnotizen... Notizen (Strg+Eingabe speichert) diff --git a/Strings/en-US.xaml b/Strings/en-US.xaml index 5e52e93e..d6ce3ae4 100644 --- a/Strings/en-US.xaml +++ b/Strings/en-US.xaml @@ -458,6 +458,9 @@ Your settings will not be removed. Delete {0}? Its saved scans, device names, approvals and history leave KillerScan. The files are kept in the Locations "deleted" folder, so they can be recovered. Delete Stop the scan before changing Location. + Discard these results? + These Temporary scan results are not saved anywhere. Changing Location clears them. To keep them, choose Save results to Location instead. + Discard Locations could not be set up, so KillerScan is using your existing devices, saved scans and history as before. {0} Nothing was changed or removed. KillerScan will try again the next time it starts. Location notes... Notes (Ctrl+Enter saves) diff --git a/Strings/es.xaml b/Strings/es.xaml index 51cabde9..20e17bec 100644 --- a/Strings/es.xaml +++ b/Strings/es.xaml @@ -416,6 +416,9 @@ Su configuración no se eliminará. ¿Eliminar {0}? Sus escaneos guardados, nombres de dispositivos, aprobaciones e historial se quitarán de KillerScan. Los archivos se conservan en la carpeta "deleted" de las Ubicaciones, así que se pueden recuperar. Eliminar Detenga el escaneo antes de cambiar de Ubicación. + ¿Descartar estos resultados? + Estos resultados de «Escaneo temporal» no se guardan en ningún sitio. Cambiar de Ubicación los borra. Para conservarlos, elija «Guardar resultados en la Ubicación». + Descartar No se pudieron configurar las Ubicaciones, así que KillerScan usa sus dispositivos, escaneos guardados e historial existentes como antes. {0} No se cambió ni se eliminó nada. KillerScan lo intentará de nuevo la próxima vez que se inicie. Notas de la Ubicación... Notas (Ctrl+Enter guarda) diff --git a/Strings/fr-FR.xaml b/Strings/fr-FR.xaml index bfa62ce5..c7f4139c 100644 --- a/Strings/fr-FR.xaml +++ b/Strings/fr-FR.xaml @@ -416,6 +416,9 @@ Vos paramètres ne seront pas supprimés. Supprimer {0} ? Ses analyses enregistrées, noms d’appareils, approbations et historique seront retirés de KillerScan. Les fichiers sont conservés dans le dossier "deleted" des Sites, ils peuvent donc être récupérés. Supprimer Arrêtez l’analyse avant de changer de Site. + Abandonner ces résultats ? + Les résultats de cette « Analyse temporaire » ne sont enregistrés nulle part. Changer de Site les efface. Pour les conserver, choisissez plutôt « Enregistrer les résultats dans le Site ». + Abandonner Les Sites n’ont pas pu être configurés, KillerScan utilise donc vos appareils, analyses enregistrées et historique existants comme avant. {0} Rien n’a été modifié ni supprimé. KillerScan réessaiera au prochain démarrage. Notes du Site... Notes (Ctrl+Enter pour enregistrer) diff --git a/Strings/hu-HU.xaml b/Strings/hu-HU.xaml index 77ac3712..fc9a04c7 100644 --- a/Strings/hu-HU.xaml +++ b/Strings/hu-HU.xaml @@ -432,6 +432,9 @@ A beállítások nem törlődnek. Törli ezt: {0}? A mentett vizsgálatai, eszköznevei, jóváhagyásai és előzményei kikerülnek a KillerScan alkalmazásból. A fájlok a Helyszínek "deleted" mappájában maradnak, így visszaállíthatók. Törlés Állítsa le a vizsgálatot a Helyszín váltása előtt. + Elveti ezeket az eredményeket? + Az „Ideiglenes vizsgálat” eredményei sehol sincsenek elmentve. A Helyszín váltása törli őket. Megtartásukhoz válassza helyette az „Eredmények mentése a Helyszínre” lehetőséget. + Elvetés A Helyszíneket nem sikerült beállítani, ezért a KillerScan a korábbiakhoz hasonlóan a meglévő eszközeit, mentett vizsgálatait és előzményeit használja. {0} Semmi sem módosult, és semmi sem lett eltávolítva. A KillerScan a következő indításkor újra megpróbálja. Helyszín jegyzetei... Jegyzetek (Ctrl+Enter: mentés) diff --git a/Strings/it-IT.xaml b/Strings/it-IT.xaml index cf4d7c08..c998cce1 100644 --- a/Strings/it-IT.xaml +++ b/Strings/it-IT.xaml @@ -432,6 +432,9 @@ Le tue impostazioni non verranno rimosse. Eliminare {0}? Le sue scansioni salvate, i nomi dei dispositivi, le approvazioni e la cronologia verranno rimossi da KillerScan. I file restano nella cartella "deleted" delle Sedi, quindi possono essere recuperati. Elimina Interrompi la scansione prima di cambiare Sede. + Scartare questi risultati? + I risultati di questa «Scansione temporanea» non vengono salvati da nessuna parte. Cambiare Sede li cancella. Per conservarli, scegli invece «Salva i risultati nella Sede». + Scarta Non è stato possibile configurare le Sedi, quindi KillerScan usa come prima i dispositivi, le scansioni salvate e la cronologia esistenti. {0} Nulla è stato modificato o rimosso. KillerScan riproverà al prossimo avvio. Note della Sede... Note (Ctrl+Enter per salvare) diff --git a/Strings/ja-JP.xaml b/Strings/ja-JP.xaml index 1e849da2..d4650c14 100644 --- a/Strings/ja-JP.xaml +++ b/Strings/ja-JP.xaml @@ -427,6 +427,9 @@ {0} を削除しますか?保存済みスキャン、デバイス名、承認、履歴は KillerScan に表示されなくなります。ファイルはロケーションの "deleted" フォルダーに保持されるため、復元できます。 削除 ロケーションを変更する前にスキャンを停止してください。 + この結果を破棄しますか? + この「一時スキャン」の結果はどこにも保存されていません。ロケーションを変更すると消去されます。残す場合は、代わりに「結果をロケーションに保存」を選択してください。 + 破棄 ロケーションを設定できなかったため、KillerScan は既存のデバイス、保存済みスキャン、履歴をこれまでどおり使用しています。 {0} 変更や削除は行われていません。KillerScan は次回の起動時に再試行します。 ロケーションのメモ... メモ (Ctrl+Enter で保存) diff --git a/Strings/kk-KZ.xaml b/Strings/kk-KZ.xaml index 79249cfc..af0df548 100644 --- a/Strings/kk-KZ.xaml +++ b/Strings/kk-KZ.xaml @@ -458,6 +458,9 @@ {0} жойылсын ба? Оның сақталған сканерлеулері, құрылғы атаулары, мақұлдаулары және тарихы KillerScan ішінде енді көрсетілмейді. Файлдар Локациялардың "deleted" қалтасында сақталады, сондықтан оларды қалпына келтіруге болады. Жою Локацияны ауыстырмас бұрын сканерлеуді тоқтатыңыз. + Бұл нәтижелерді жою керек пе? + Бұл «Уақытша сканерлеу» нәтижелері ешқайда сақталмаған. Локацияны ауыстырсаңыз, олар өшіріледі. Оларды сақтап қалу үшін оның орнына «Нәтижелерді Локацияға сақтау» тармағын таңдаңыз. + Жою Локацияларды орнату мүмкін болмады, сондықтан KillerScan бұрынғыдай бар құрылғыларыңызды, сақталған сканерлеулерді және тарихты пайдаланады. {0} Ештеңе өзгертілген немесе жойылған жоқ. KillerScan келесі іске қосылғанда қайта әрекет етеді. Локация жазбалары... Жазбалар (Ctrl+Enter сақтайды) diff --git a/Strings/nb-NO.xaml b/Strings/nb-NO.xaml index ab7b1dc6..a843716a 100644 --- a/Strings/nb-NO.xaml +++ b/Strings/nb-NO.xaml @@ -458,6 +458,9 @@ Innstillingene dine fjernes ikke. Slette {0}? De lagrede skanningene, enhetsnavnene, godkjenningene og skanneloggen fjernes fra KillerScan. Filene beholdes i "deleted"-mappen for Lokasjoner, så de kan gjenopprettes. Slett Stopp skanningen før du bytter Lokasjon. + Forkaste disse resultatene? + Disse resultatene fra «Midlertidig skanning» er ikke lagret noe sted. Å bytte Lokasjon fjerner dem. For å beholde dem, velg «Lagre resultater i Lokasjonen» i stedet. + Forkast Lokasjoner kunne ikke settes opp, så KillerScan bruker eksisterende enheter, lagrede skanninger og skanneloggen som før. {0} Ingenting ble endret eller fjernet. KillerScan prøver igjen neste gang programmet starter. Lokasjonsnotater... Notater (Ctrl+Enter lagrer) diff --git a/Strings/pl-PL.xaml b/Strings/pl-PL.xaml index 83a00205..3d998828 100644 --- a/Strings/pl-PL.xaml +++ b/Strings/pl-PL.xaml @@ -401,6 +401,9 @@ Twoje ustawienia nie zostaną usunięte. Usunąć {0}? Jej zapisane skany, nazwy urządzeń, zatwierdzenia i historia znikną z KillerScan. Pliki zostaną zachowane w folderze Lokalizacji "deleted", więc można je odzyskać. Usuń Zatrzymaj skanowanie przed zmianą Lokalizacji. + Odrzucić te wyniki? + Wyniki z trybu „Skanowanie tymczasowe” nie są nigdzie zapisane. Zmiana Lokalizacji je usunie. Aby je zachować, wybierz zamiast tego „Zapisz wyniki w Lokalizacji”. + Odrzuć Nie udało się skonfigurować Lokalizacji, więc KillerScan używa dotychczasowych urządzeń, zapisanych skanów i historii tak jak wcześniej. {0} Niczego nie zmieniono ani nie usunięto. KillerScan spróbuje ponownie przy następnym uruchomieniu. Notatki Lokalizacji... Notatki (Ctrl+Enter zapisuje) diff --git a/Strings/pt-BR.xaml b/Strings/pt-BR.xaml index d87a757c..e7628d82 100644 --- a/Strings/pt-BR.xaml +++ b/Strings/pt-BR.xaml @@ -458,6 +458,9 @@ Suas configurações não serão removidas. Excluir {0}? As verificações salvas, os nomes de dispositivos, as aprovações e o histórico dele saem do KillerScan. Os arquivos ficam guardados na pasta "deleted" dos Locais, então podem ser recuperados. Excluir Pare a verificação antes de trocar de Local. + Descartar estes resultados? + Estes resultados de “Verificação temporária” não são salvos em nenhum lugar. Trocar de Local os apaga. Para mantê-los, escolha “Salvar resultados no Local”. + Descartar Não foi possível configurar os Locais, então o KillerScan está usando seus dispositivos, verificações salvas e histórico existentes como antes. {0} Nada foi alterado ou removido. O KillerScan tentará novamente na próxima vez que for iniciado. Notas do Local... Notas (Ctrl+Enter salva) diff --git a/Strings/ru-RU.xaml b/Strings/ru-RU.xaml index 317bd3da..34e138f0 100644 --- a/Strings/ru-RU.xaml +++ b/Strings/ru-RU.xaml @@ -442,6 +442,9 @@ Удалить {0}? Его сохранённые сканирования, имена устройств, одобрения и история будут убраны из KillerScan. Файлы сохранятся в папке Объектов "deleted", поэтому их можно восстановить. Удалить Остановите сканирование, прежде чем менять Объект. + Сбросить эти результаты? + Результаты режима «Временное сканирование» нигде не сохраняются. При смене Объекта они будут удалены. Чтобы оставить их, выберите вместо этого «Сохранить результаты в Объект». + Сбросить Не удалось настроить Объекты, поэтому KillerScan, как и раньше, использует ваши существующие устройства, сохранённые сканирования и историю. {0} Ничего не изменено и не удалено. KillerScan попробует снова при следующем запуске. Заметки об Объекте... Заметки (Ctrl+Enter сохраняет) diff --git a/Strings/sl-SI.xaml b/Strings/sl-SI.xaml index 1c353850..737e69a9 100644 --- a/Strings/sl-SI.xaml +++ b/Strings/sl-SI.xaml @@ -391,6 +391,9 @@ Tvoje nastavitve bodo ohranjene. Izbrišem {0}? KillerScan ne bo več prikazoval njenih shranjenih pregledov, imen naprav, odobritev in zgodovine. Datoteke ostanejo v mapi Lokacij "deleted", zato jih je mogoče obnoviti. Izbriši Pred menjavo Lokacije ustavi pregled. + Želiš zavreči te rezultate? + Rezultati načina »Začasni pregled« niso nikjer shranjeni. Menjava Lokacije jih izbriše. Če jih želiš obdržati, raje izberi »Shrani rezultate v Lokacijo«. + Zavrzi Lokacij ni bilo mogoče nastaviti, zato KillerScan uporablja tvoje obstoječe naprave, shranjene preglede in zgodovino kot doslej. {0} Nič ni bilo spremenjeno ali odstranjeno. KillerScan bo poskusil znova ob naslednjem zagonu. Opombe Lokacije... Opombe (Ctrl+Enter shrani) diff --git a/Strings/tr-TR.xaml b/Strings/tr-TR.xaml index a54521c7..a2d4a4cb 100644 --- a/Strings/tr-TR.xaml +++ b/Strings/tr-TR.xaml @@ -416,6 +416,9 @@ Ayarlarınız silinmeyecek. {0} silinsin mi? Kayıtlı taramaları, cihaz adları, onayları ve geçmişi KillerScan'den kaldırılır. Dosyalar Lokasyonların "deleted" klasöründe tutulur, böylece geri yüklenebilir. Sil Lokasyonu değiştirmeden önce taramayı durdurun. + Bu sonuçlar atılsın mı? + Bu “Geçici tarama” sonuçları hiçbir yere kaydedilmez. Lokasyonu değiştirmek bunları temizler. Saklamak için bunun yerine “Sonuçları Lokasyona kaydet” seçeneğini kullanın. + At Lokasyonlar ayarlanamadı, bu nedenle KillerScan mevcut cihazlarınızı, kayıtlı taramalarınızı ve geçmişinizi eskisi gibi kullanıyor. {0} Hiçbir şey değiştirilmedi veya kaldırılmadı. KillerScan bir sonraki açılışta yeniden deneyecek. Lokasyon notları... Notlar (Ctrl+Enter kaydeder) diff --git a/Strings/uk-UA.xaml b/Strings/uk-UA.xaml index 99a5c215..660e6f16 100644 --- a/Strings/uk-UA.xaml +++ b/Strings/uk-UA.xaml @@ -458,6 +458,9 @@ Видалити {0}? Її збережені сканування, імена пристроїв, схвалення та історія зникнуть із KillerScan. Файли зберігаються в папці локацій «deleted», тож їх можна відновити. Видалити Зупиніть сканування, перш ніж змінювати локацію. + Відкинути ці результати? + Результати режиму «Тимчасове сканування» ніде не зберігаються. Зміна локації очистить їх. Щоб зберегти їх, виберіть натомість «Зберегти результати в локацію». + Відкинути Не вдалося налаштувати локації, тому KillerScan використовує ваші наявні пристрої, збережені сканування та історію, як і раніше. {0} Нічого не змінено й не видалено. KillerScan спробує знову під час наступного запуску. Нотатки локації... Нотатки (Ctrl+Enter зберігає) diff --git a/Strings/vi-VN.xaml b/Strings/vi-VN.xaml index f4ddf660..43ecc4c7 100644 --- a/Strings/vi-VN.xaml +++ b/Strings/vi-VN.xaml @@ -396,6 +396,9 @@ Cài đặt của bạn sẽ không bị xóa. Xóa {0}? Các lần quét đã lưu, tên thiết bị, phê duyệt và lịch sử của địa điểm này sẽ rời khỏi KillerScan. Các tệp được giữ trong thư mục "deleted" của địa điểm, nên có thể khôi phục. Xóa Hãy dừng quét trước khi đổi địa điểm. + Bỏ các kết quả này? + Kết quả của “Lần quét tạm thời” này không được lưu ở đâu cả. Đổi địa điểm sẽ xóa chúng. Để giữ lại, hãy chọn “Lưu kết quả vào địa điểm”. + Bỏ Không thể thiết lập địa điểm, nên KillerScan vẫn dùng thiết bị, các lần quét đã lưu và lịch sử hiện có như trước. {0} Không có gì bị thay đổi hay xóa. KillerScan sẽ thử lại vào lần khởi động tới. Ghi chú địa điểm... Ghi chú (Ctrl+Enter để lưu) diff --git a/Strings/zh-CN.xaml b/Strings/zh-CN.xaml index 749b95c2..7c50d604 100644 --- a/Strings/zh-CN.xaml +++ b/Strings/zh-CN.xaml @@ -416,6 +416,9 @@ 要删除 {0} 吗?其已保存的扫描、设备名称、批准记录和历史记录将从 KillerScan 中移除。文件会保留在站点的“deleted”文件夹中,因此可以恢复。 删除 请先停止扫描,再切换站点。 + 放弃这些结果? + 此“临时扫描”的结果未保存在任何位置。切换站点会清除这些结果。如需保留,请改为选择“将结果保存到站点”。 + 放弃 无法设置站点,因此 KillerScan 将照常使用现有的设备、已保存的扫描和历史记录。 {0} 未更改或删除任何内容。KillerScan 将在下次启动时重试。 站点备注... 备注(Ctrl+Enter 保存) diff --git a/Strings/zh-TW.xaml b/Strings/zh-TW.xaml index f4912c04..b4501c19 100644 --- a/Strings/zh-TW.xaml +++ b/Strings/zh-TW.xaml @@ -416,6 +416,9 @@ 要刪除 {0} 嗎?其已儲存的掃描、裝置名稱、核准記錄與歷史記錄將從 KillerScan 中移除。檔案會保留在站點的「deleted」資料夾中,因此可以復原。 刪除 請先停止掃描,再切換站點。 + 捨棄這些結果? + 此「暫時掃描」的結果未儲存在任何位置。切換站點會清除這些結果。如需保留,請改為選擇「將結果儲存到站點」。 + 捨棄 無法設定站點,因此 KillerScan 會照常使用您現有的裝置、已儲存的掃描與歷史記錄。 {0} 未變更或移除任何內容。KillerScan 會在下次啟動時重試。 站點備註... 備註(Ctrl+Enter 儲存) diff --git a/docs/locations.md b/docs/locations.md index 75ab1b9f..6b42f645 100644 --- a/docs/locations.md +++ b/docs/locations.md @@ -120,6 +120,10 @@ Where 1.8.4 differs from the plan above: - **One scan workspace.** The app has a single scan workspace rather than tabs, so "the active tab" is that workspace. Its Location drives the History and Saved scans sidebar lists, the status tooltip, SSH usernames and the terminal's Scan button. - **Starting Location.** The workspace starts in the Location used last. After migration that is Unassigned, so an upgrade looks the same as before. A fresh install starts as a Temporary scan. +- **Changing Location clears the results on screen.** Otherwise renaming, approving or typing a row would write into the newly picked Location. A completed run is already in its own Location's history. A Temporary scan's results are nowhere else, so leaving them asks first and points to Save results to Location, which keeps them on screen in the Location they move to. Picking the Location the tab is already in changes nothing. +- **Migration across windows.** Migration holds a named lock, so two windows starting together migrate once; the second finds the catalog and loads it. A `locations\unassigned` folder left by an interrupted migration is moved to `locations\.deleted`, never deleted. +- **An unreadable catalog.** A catalog that is corrupt, from a newer version, or empty is reported at startup and its file is left as it is. Before migration has published a catalog, the app keeps using the original files. After it has, those files are a stale backup, so the app runs Temporary scans that save nothing until the catalog reads again. +- **Import checks.** Every store in a `.ksloc` file must be a JSON object or list, and the staged copy is opened the way the app will open it before the Location is added. A file that cannot be read adds nothing. - **Device mark.** Green is approved, amber is new since the baseline, and the muted text color is known but not approved. A Temporary scan shows everything as known. - **Suggestion.** Matches on gateway MAC only. Each completed scan records the MAC of the device at the gateway address in its Location. SSID and subnet are not used yet. Dismiss hides a gateway's suggestion until the app restarts. - **Adapter binding.** Scan settings has a Network adapter choice (Automatic, or a connected adapter), and `/adapter` and `-Adapter` do the same from the command line. A chosen adapter sends pings, TCP and UDP probes, HTTP and TLS fingerprints, ARP and mDNS/SSDP from its own address. Saved scans keep the choice; a chosen adapter that is not connected asks before falling back to Windows' route. A bound scan reaches the adapter's own network, and routed targets only when Windows' best route to them already goes through that adapter (hosts behind a VPN adapter, for example). A target Windows would send out another adapter, or has no route to, is refused before any traffic, and the message names the adapter Windows would use. KillerScan never adds or forces routes. Name lookups still use the system resolver. Every run records the adapter it used. diff --git a/tests/SpeedTest.Tests/LocationWorkspaceTests.cs b/tests/SpeedTest.Tests/LocationWorkspaceTests.cs index 74d862de..4809d670 100644 --- a/tests/SpeedTest.Tests/LocationWorkspaceTests.cs +++ b/tests/SpeedTest.Tests/LocationWorkspaceTests.cs @@ -83,6 +83,8 @@ internal static void Run() !a.Devices.IsTrusted(device) && workspace.Location == a, "Saving results adds the run and its names to the Location, approves nothing, and moves the tab there."); Require(!workspace.SaveResultsTo(b), "Only a Temporary scan's results can be saved to a Location."); + Require(workspace.Session.Devices.Count == 1, "Saved results stay on screen in the Location they moved to."); + ResultsFollowLocation(a, b, scanner); Suggestion(Path.Combine(root, "service")); AdapterPreference(Path.Combine(root, "adapter")); } @@ -95,6 +97,36 @@ internal static void Run() Console.WriteLine("PASS: the scan tab names, records and baselines through its own Location."); } + // Results on screen belong to the Location they were scanned in, so switching clears them + // rather than letting a rename or approval land in the new Location. + private static void ResultsFollowLocation(LocationData a, LocationData b, SiteScanner scanner) + { + using var workspace = new ScanWorkspace("192.168.1.10", false, scanner); + workspace.SetLocation(a); + workspace.Scan(); + PumpUntil(() => !workspace.IsScanning && workspace.Session.Devices.Count == 1); + + Require(workspace.SetLocation(a) && workspace.Session.Devices.Count == 1 && workspace.Location == a, + "Picking the current Location again changes nothing, results included."); + Require(workspace.SetLocation(b) && workspace.Session.Devices.Count == 0 && workspace.Location == b, + "Switching Location clears the results scanned in the old one."); + + var temporary = LocationData.Temporary(); + workspace.SetLocation(temporary); + workspace.Scan(); + PumpUntil(() => !workspace.IsScanning && workspace.Session.Devices.Count == 1); + string? asked = null; + workspace.ConfirmDiscard = message => { asked = message; return false; }; + Require(!workspace.SetLocation(a) && workspace.Location == temporary && workspace.Session.Devices.Count == 1 && + asked == (string)Application.Current.FindResource("Str_Loc_DiscardTemporary"), + "Leaving unsaved Temporary results asks first, and No keeps them."); + Require(workspace.SetLocation(LocationData.Temporary()) && workspace.Session.Devices.Count == 1, + "Picking Temporary scan while already in one changes nothing."); + workspace.ConfirmDiscard = _ => true; + Require(workspace.SetLocation(a) && workspace.Location == a && workspace.Session.Devices.Count == 0, + "Discarding moves to the Location with an empty tab."); + } + // The banner offers a Location this gateway was seen in, and never switches by itself. private static void Suggestion(string root) { @@ -193,6 +225,15 @@ private static void AdapterPreference(string root) workspace.LoadSavedScan(site.SavedScans.Items.Single()); Require(workspace.AdapterChoice == "Ethernet" && workspace.Targets == "192.168.1.0/24", "Loading a saved scan restores its adapter."); + // Save in place records the adapter chosen now, including going back to Automatic. + var save = typeof(ScanWorkspace).GetMethod("SaveScan", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + workspace.AdapterChoice = string.Empty; + save.Invoke(workspace, null); + Require(site.SavedScans.Items.Single().Adapter.Length == 0, "Save records a change back to Automatic."); + workspace.AdapterChoice = "Ethernet"; + save.Invoke(workspace, null); + Require(site.SavedScans.Items.Single().Adapter == "Ethernet", "Save records a newly chosen adapter."); + connected = false; string? asked = null; workspace.ConfirmAdapter = message => { asked = message; return false; }; From 1439dc18858aca43e85195a44a9def1f3fd37ca8 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:11:14 -0700 Subject: [PATCH 116/333] Light theme: darker gray chrome around lighter fields (family) --- CHANGELOG.md | 1 + Themes/Light.xaml | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d63c400b..9c90a7fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Right-click a device and choose Access to see every way into it: each web port and what it answered (a status, a login request or a sign-in page), the SSH banner, and every other open port. Clicking an entry opens it in the browser, SSH or Remote Desktop on that port, and Copy Address copies any of them. Nothing is claimed beyond what was seen, no credentials are ever sent, and opening the menu sends nothing. Deep scans now check every web port so each has its own evidence. CSV adds an Access column, HTML reports list the endpoints when hovering a device's ports, and CLI JSON and the PowerShell module carry the full list. ### Changed +- Light's background, sidebar and footer are a slightly darker gray (#d4d4d4), and fields a touch lighter, so content stands apart from the chrome. - Open in Browser opens the web port that actually answered, HTTPS first, instead of always `http://` on port 80. The Services view opens a port with the scheme it answered with, including on non-standard ports. - A name containing a device keyword, such as "synology" or "pihole", is now heavy evidence rather than the final word, so clearer protocol answers can outvote it. A UniFi or UBNT name now shows Switch/AP instead of the generic Network type. - Trusted devices are now called approved devices, and approving is always a choice you make. diff --git a/Themes/Light.xaml b/Themes/Light.xaml index a31c13fb..593e2634 100644 --- a/Themes/Light.xaml +++ b/Themes/Light.xaml @@ -22,8 +22,8 @@ - - + + @@ -119,7 +119,7 @@ 32 0.18 - + 0.55 0.45 From 8194d57c674db0c25d0e21726b90c82bd78b292e Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:28:40 -0700 Subject: [PATCH 117/333] v1.8.4: Name the app in the changelog heading --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c90a7fe..3c5f95b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changelog +# KillerScan Changelog All notable changes to KillerScan are documented here. From 8ac2fbfc855a7e20d381e141e50fa55e627626ab Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:29:12 -0700 Subject: [PATCH 118/333] v1.8.4: Darken menus and bars in Dark, Decay, Ectoplasm, Malaise and Mourning --- Themes/Dark.xaml | 2 +- Themes/Decay.xaml | 2 +- Themes/Ectoplasm.xaml | 2 +- Themes/Malaise.xaml | 2 +- Themes/Mourning.xaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Themes/Dark.xaml b/Themes/Dark.xaml index 84ece25b..b97747df 100644 --- a/Themes/Dark.xaml +++ b/Themes/Dark.xaml @@ -46,7 +46,7 @@ - + diff --git a/Themes/Decay.xaml b/Themes/Decay.xaml index 14895eb8..ef64f2b9 100644 --- a/Themes/Decay.xaml +++ b/Themes/Decay.xaml @@ -66,7 +66,7 @@ - + diff --git a/Themes/Ectoplasm.xaml b/Themes/Ectoplasm.xaml index 008a28d2..0b47796b 100644 --- a/Themes/Ectoplasm.xaml +++ b/Themes/Ectoplasm.xaml @@ -58,7 +58,7 @@ - + diff --git a/Themes/Malaise.xaml b/Themes/Malaise.xaml index 8bda81f1..9b3dca66 100644 --- a/Themes/Malaise.xaml +++ b/Themes/Malaise.xaml @@ -62,7 +62,7 @@ - + diff --git a/Themes/Mourning.xaml b/Themes/Mourning.xaml index c2edbc98..c62d537b 100644 --- a/Themes/Mourning.xaml +++ b/Themes/Mourning.xaml @@ -54,7 +54,7 @@ - + From 982e7cf9177ade65f900f2a2c603d291fa6db52b Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:36:29 -0700 Subject: [PATCH 119/333] v1.8.4: Soften pane borders in Blood, Greed and Cyanotic, raise them in Black --- Themes/Black.xaml | 2 +- Themes/Blood.xaml | 2 +- Themes/Cyanotic.xaml | 2 +- Themes/Greed.xaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Themes/Black.xaml b/Themes/Black.xaml index 19c95cbf..64e9871b 100644 --- a/Themes/Black.xaml +++ b/Themes/Black.xaml @@ -25,7 +25,7 @@ - + diff --git a/Themes/Blood.xaml b/Themes/Blood.xaml index 359362a2..49bcb320 100644 --- a/Themes/Blood.xaml +++ b/Themes/Blood.xaml @@ -33,7 +33,7 @@ - + diff --git a/Themes/Cyanotic.xaml b/Themes/Cyanotic.xaml index 1c855dd5..19afe7de 100644 --- a/Themes/Cyanotic.xaml +++ b/Themes/Cyanotic.xaml @@ -28,7 +28,7 @@ - + diff --git a/Themes/Greed.xaml b/Themes/Greed.xaml index df3f40a8..d51a40df 100644 --- a/Themes/Greed.xaml +++ b/Themes/Greed.xaml @@ -28,7 +28,7 @@ - + From 9499ed903b9512f5e78160f18ab0cf2307b85327 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:41:49 -0700 Subject: [PATCH 120/333] v1.8.4: Show film grain on Light and other non-Black themes again --- Controls/GrainTexture.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Controls/GrainTexture.cs b/Controls/GrainTexture.cs index f1270616..b29601a4 100644 --- a/Controls/GrainTexture.cs +++ b/Controls/GrainTexture.cs @@ -142,7 +142,9 @@ internal static BitmapSource Publish() } var lean = CurrentLean(); - bool highlightsOnly = Application.Current?.TryFindResource("GrainHighlightsOnly") is bool enabled ? enabled : true; + // Only a theme that asks (Black) gets bright specks alone. Defaulting to true gave every + // theme light-only grain: Light turned white and the dark themes' surfaces ran together. + bool highlightsOnly = Application.Current?.TryFindResource("GrainHighlightsOnly") is bool enabled && enabled; int key = lean is Color c ? (c.R << 16) | (c.G << 8) | c.B : -1; key = (key + 1) * 2 + (highlightsOnly ? 1 : 0); if (_current != null && key == _currentKey) return _current; From 721ee6f725c08b4306b1ddea3636af399f4ec3f6 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:42:59 -0700 Subject: [PATCH 121/333] v1.8.4: Keep redirects off print ports and bound probes on their adapter Same-address redirects to a raw print port are no longer followed, and a redirect is still recorded when the page it points to fails or times out. On .NET Framework, fingerprint requests close their connections, a striped lock keeps bound and unbound requests from swapping a shared service point's bind delegate, and a failed bind fails the request instead of retrying or falling back to another adapter. Bound scans seed hosts from their own adapter's ARP table only, and adapter names are reread after a network change. The proxy and DNS tests no longer send traffic off the PC or flush its DNS cache. --- Engine/Platform/WindowsNetworkPlatform.cs | 19 ++++- Engine/Scanning/NetworkScanner.cs | 76 +++++++++++++++---- docs/access.md | 2 +- tests/KillerScan.Engine.Tests/AccessTests.cs | 26 +++++++ .../KillerScan.Engine.Tests/DnsBudgetTests.cs | 4 +- .../KillerScan.Engine.Tests/SafeProbeTests.cs | 25 +++++- 6 files changed, 130 insertions(+), 22 deletions(-) diff --git a/Engine/Platform/WindowsNetworkPlatform.cs b/Engine/Platform/WindowsNetworkPlatform.cs index c6e9e189..4e7304e4 100644 --- a/Engine/Platform/WindowsNetworkPlatform.cs +++ b/Engine/Platform/WindowsNetworkPlatform.cs @@ -28,13 +28,28 @@ private struct ForwardRow [DllImport("iphlpapi.dll")] private static extern uint GetBestRoute(uint destination, uint source, out ForwardRow route); + public WindowsNetworkPlatform() + { + // An adapter renamed, or an index reused after a VPN reconnects, would otherwise keep + // its old name here and make a bound scan refuse a target it can reach. + NetworkChange.NetworkAddressChanged += (_, _) => _interfaceNames.Clear(); + } + /// Reads the system ARP cache via arp -a. - public IReadOnlyDictionary ReadNeighborCache() + public IReadOnlyDictionary ReadNeighborCache() => RunArp("-a"); + + /// + /// The ARP cache of the one adapter with this address (arp -a -N). A bound scan seeds + /// hosts only from its own adapter, even when another adapter carries the same subnet. + /// + internal IReadOnlyDictionary ReadNeighborCacheOn(IPAddress local) => RunArp("-a -N " + local); + + private static IReadOnlyDictionary RunArp(string arguments) { var cache = new Dictionary(); try { - var psi = new ProcessStartInfo("arp", "-a") + var psi = new ProcessStartInfo("arp", arguments) { RedirectStandardOutput = true, UseShellExecute = false, diff --git a/Engine/Scanning/NetworkScanner.cs b/Engine/Scanning/NetworkScanner.cs index 1d448986..056e8bef 100644 --- a/Engine/Scanning/NetworkScanner.cs +++ b/Engine/Scanning/NetworkScanner.cs @@ -384,9 +384,18 @@ public virtual async Task> ScanSubnetAsync( return devices; } - /// The neighbor (ARP) cache from the platform. - private static IReadOnlyDictionary GetArpCache() => - NetworkPlatform.Current.ReadNeighborCache(); + /// The neighbor (ARP) cache from the platform; a bound run reads only its own adapter's. + private static IReadOnlyDictionary GetArpCache() + { + if (ScanSource.Current is { } binding && NetworkPlatform.Current is WindowsNetworkPlatform windows + && System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) + { +#pragma warning disable CA1416 + return windows.ReadNeighborCacheOn(binding.LocalAddress); +#pragma warning restore CA1416 + } + return NetworkPlatform.Current.ReadNeighborCache(); + } /// /// Probe a single host for hostname, open ports, fingerprints, and device type. @@ -1331,15 +1340,14 @@ internal static bool HasLoginForm(string html) for (int hop = 0; hop <= 2; hop++) { ct.ThrowIfCancellationRequested(); -#if NETFRAMEWORK - // .NET Framework's handler connects through the URI's service point, whose bind - // delegate chooses the local address. It is set for every request so an unbound - // run never inherits a binding left on a shared service point. - var local = ScanSource.Current?.LocalAddress; - ServicePointManager.FindServicePoint(uri).BindIPEndPointDelegate = - local == null ? null : (_, _, _) => new IPEndPoint(local, 0); -#endif - var response = await client.GetAsync(uri, ct); + HttpResponseMessage response; + try { response = await SendFingerprintAsync(client, uri, ct); } + catch (Exception) when (firstRedirect != null && !ct.IsCancellationRequested) + { + // The redirect was an answer at this endpoint, even when the hop it pointed to + // fails or times out (HttpClient reports its own timeout as a cancellation). + return (null, uri, firstRedirect); + } int status = (int)response.StatusCode; if (status < 300 || status >= 400) return (response, uri, firstRedirect); firstRedirect ??= status; @@ -1348,13 +1356,55 @@ internal static bool HasLoginForm(string html) if (hop == 2 || status is not (301 or 302 or 303 or 307 or 308) || location == null || !Uri.TryCreate(uri, location, out var next) || (next.Scheme != Uri.UriSchemeHttp && next.Scheme != Uri.UriSchemeHttps) || - next.UserInfo.Length != 0 || !IPAddress.TryParse(next.Host, out var target) || !target.Equals(address)) + next.UserInfo.Length != 0 || !IPAddress.TryParse(next.Host, out var target) || !target.Equals(address) || + // A redirect to a raw print port would make the GET print, like any payload there. + !IsPayloadSafe(next.Port)) return (null, uri, firstRedirect); uri = next; } return (null, uri, firstRedirect); } +#if NETFRAMEWORK + // .NET Framework connects through a service point shared by every request to the same + // scheme, host and port, and its bind delegate is read when the connection opens. One + // lock per stripe of endpoints keeps a bound and an unbound request to the same endpoint + // from swapping each other's delegate in between. + private static readonly SemaphoreSlim[] ServicePointLocks = [.. Enumerable.Range(0, 32).Select(_ => new SemaphoreSlim(1, 1))]; +#endif + + /// + /// One fingerprint request. It closes its connection afterwards, so a connection opened + /// from one adapter is never reused by a later request that should go from another. + /// + private static async Task SendFingerprintAsync(HttpClient client, Uri uri, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.ConnectionClose = true; +#if NETFRAMEWORK + var gate = ServicePointLocks[(uri.Scheme + uri.Authority).GetHashCode() & 31]; + await gate.WaitAsync(ct); + try + { + var local = ScanSource.Current?.LocalAddress; + ServicePointManager.FindServicePoint(uri).BindIPEndPointDelegate = + local == null ? null : (_, _, retry) => BindOnce(local, retry); + return await client.SendAsync(request, ct); + } + finally { gate.Release(); } +#else + return await client.SendAsync(request, ct); +#endif + } + +#if NETFRAMEWORK + // The service point calls the delegate again after each failed bind. Retrying the same + // address would spin, and returning null would let Windows pick another adapter, so a + // second call fails the request instead. + private static IPEndPoint BindOnce(IPAddress local, int retry) => + retry == 0 ? new IPEndPoint(local, 0) : throw new InvalidOperationException("The bound adapter's address " + local + " is no longer available."); +#endif + /// /// Read SSH server banner (first line after TCP connect) from port 22. /// Returns strings like "SSH-2.0-OpenSSH_9.2p1" or "SSH-2.0-Cisco-1.25". diff --git a/docs/access.md b/docs/access.md index af710917..00d232d9 100644 --- a/docs/access.md +++ b/docs/access.md @@ -28,7 +28,7 @@ KillerScan never sends credentials, never logs in, and never stores passwords. Addresses are built only from the device's numeric IP and the endpoint's protocol and port, never from anything the device sent, such as a redirect target. Only `http`, `https`, `ssh`, `rdp` and `tcp` with ports 1 to 65535 are allowed, and devices without a numeric address have no endpoints. -A redirect that stays on the same address (up to two hops, as the fingerprint already allowed) is recorded twice: the first port records the redirect status, and the page it led to is recorded under the port and scheme that served it. A redirect anywhere else is recorded as the redirect status only. +A redirect that stays on the same address (up to two hops, as the fingerprint already allowed) is recorded twice: the first port records the redirect status, and the page it led to is recorded under the port and scheme that served it. A redirect anywhere else, or to a raw print port (9100 to 9103 or 515), is recorded as the redirect status only and not followed. A redirect is recorded even when the page it points to does not answer. ## What is recorded diff --git a/tests/KillerScan.Engine.Tests/AccessTests.cs b/tests/KillerScan.Engine.Tests/AccessTests.cs index 5f778042..8cb81c21 100644 --- a/tests/KillerScan.Engine.Tests/AccessTests.cs +++ b/tests/KillerScan.Engine.Tests/AccessTests.cs @@ -42,6 +42,28 @@ internal static async Task Run() "The page is recorded under the port that served it."); } + // A same-address redirect to a raw print port is not followed: a GET there would print. + System.Net.Sockets.TcpListener? printer = null; + try { printer = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 9100); printer.Start(); } + catch (System.Net.Sockets.SocketException) { printer = null; } + try + { + using var front = new Server(IPAddress.Loopback, _ => "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:9100/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + var device = await Probe(front.Port); + Require(Single(device, front.Port).HttpStatus == 302 && device.Access!.All(e => e.Port != 9100), + "A redirect to a raw print port is recorded and not followed."); + if (printer != null) Require(!printer.Pending(), "Nothing connects to the raw print port."); + } + finally { printer?.Stop(); } + + // A redirect whose next hop fails is still an answer at the first endpoint. + var closed = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + closed.Start(); + int closedPort = ((IPEndPoint)closed.LocalEndpoint).Port; + closed.Stop(); + using (var front = new Server(IPAddress.Loopback, _ => "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:" + closedPort + "/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + Require(Single(await Probe(front.Port), front.Port).HttpStatus == 302, "A redirect is recorded even when the page it points to does not answer."); + // Normal scans stop at the first useful page; deep scans check every web port. using (var first = new Server(IPAddress.Loopback, _ => Response("200 OK", "First"))) using (var second = new Server(IPAddress.Loopback, _ => Response("401 Unauthorized", "", "WWW-Authenticate: NTLM\r\n"))) @@ -79,6 +101,10 @@ internal static async Task Run() DeviceAccess.Address("evil.example", new AccessEndpoint { Protocol = "http", Port = 80 }) == null, "Unknown schemes, bad ports and names are refused."); + // A bound scan reads its adapter's table with arp -a -N, which adds an Interface header. + var perAdapter = ArpOutput.Parse("\r\nInterface: 192.168.8.10 --- 0x15\r\n Internet Address Physical Address Type\r\n 192.168.8.1 94-83-c4-a4-78-82 dynamic\r\n"); + Require(perAdapter.Count == 1 && perAdapter["192.168.8.1"] == "94:83:C4:A4:78:82", "A single adapter's ARP table parses like the full one."); + string json = JsonSerializer.Serialize(new AccessEndpoint { Protocol = "https", Port = 443, Evidence = AccessEvidence.HttpResponse, Login = AccessLogin.LoginForm }); Require(json.Contains("\"HttpResponse\"") && json.Contains("\"LoginForm\""), "JSON uses readable evidence and login names: " + json); diff --git a/tests/KillerScan.Engine.Tests/DnsBudgetTests.cs b/tests/KillerScan.Engine.Tests/DnsBudgetTests.cs index 9354986c..f71bb4a2 100644 --- a/tests/KillerScan.Engine.Tests/DnsBudgetTests.cs +++ b/tests/KillerScan.Engine.Tests/DnsBudgetTests.cs @@ -17,7 +17,7 @@ internal static async Task Run() var scanner = new NetworkScanner(new IsolatedDiscovery()); var watch = Stopwatch.StartNew(); - var device = await scanner.DeepProbeHostAsync("127.0.0.1", CancellationToken.None, options).WaitAsyncCompat(TimeSpan.FromSeconds(10)); + var device = await scanner.DeepProbeHostAsync("127.0.0.1", CancellationToken.None, options, flushLocalDnsCache: false).WaitAsyncCompat(TimeSpan.FromSeconds(10)); Require(device.Hostname.Length == 0 && watch.Elapsed < TimeSpan.FromSeconds(6), $"A stalled lookup gives up within its budget, not {watch.Elapsed.TotalSeconds:0.0} s."); @@ -30,7 +30,7 @@ internal static async Task Run() await Task.Delay(200); int before = Volatile.Read(ref started); NetworkScanner.HostnameResolverOverride = _ => { Interlocked.Increment(ref started); return Task.FromResult("fresh.example"); }; - var after = await scanner.DeepProbeHostAsync("127.0.0.1", CancellationToken.None, options); + var after = await scanner.DeepProbeHostAsync("127.0.0.1", CancellationToken.None, options, flushLocalDnsCache: false); Require(after.Hostname == "fresh.example" && Volatile.Read(ref started) == before + 1, "Once stalled lookups finish, their slots are free for new lookups."); } diff --git a/tests/KillerScan.Engine.Tests/SafeProbeTests.cs b/tests/KillerScan.Engine.Tests/SafeProbeTests.cs index f1952ea1..f108192a 100644 --- a/tests/KillerScan.Engine.Tests/SafeProbeTests.cs +++ b/tests/KillerScan.Engine.Tests/SafeProbeTests.cs @@ -84,10 +84,18 @@ private static async Task ProxyIgnored() #endif try { - // TEST-NET-1 is never routed, so only a proxy could produce a fingerprint. - var target = IPAddress.Parse("192.0.2.123"); - var device = new NetworkDevice { IpAddress = target.ToString(), OpenPorts = [80] }; - await NetworkScanner.ProbeHttpAsync(device, target); + // A closed port on this PC's own adapter address: traffic to it never leaves the + // machine, and both runtimes would send it through a configured proxy, so only the + // proxy could produce a fingerprint. (.NET Framework skips proxies for loopback.) + var target = OwnAddress() ?? IPAddress.Parse("127.0.0.2"); + if (target.Equals(IPAddress.Parse("127.0.0.2"))) + Console.WriteLine("NOTE: no adapter address; on .NET Framework the loopback fallback does not exercise the proxy."); + var closed = new TcpListener(IPAddress.Any, 0); + closed.Start(); + int closedPort = ((IPEndPoint)closed.LocalEndpoint).Port; + closed.Stop(); + var device = new NetworkDevice { IpAddress = target.ToString(), OpenPorts = [closedPort] }; + await NetworkScanner.ProbeHttpAsync(device, target, deep: true); Require(Volatile.Read(ref requests) == 0, "Device fingerprinting sent " + requests + " request(s) through the system proxy."); Require(device.HttpTitle.Length == 0 && device.HttpServer.Length == 0, "A proxy response was attributed to the device: " + device.HttpTitle + " / " + device.HttpServer); @@ -105,6 +113,15 @@ private static async Task ProxyIgnored() Console.WriteLine("PASS: HTTP fingerprinting ignores the system proxy."); } + // An IPv4 address assigned to an adapter that is up, other than loopback and link-local. + private static IPAddress? OwnAddress() => + System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces() + .Where(n => n.OperationalStatus == System.Net.NetworkInformation.OperationalStatus.Up && + n.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Loopback) + .SelectMany(n => n.GetIPProperties().UnicastAddresses) + .Select(u => u.Address) + .FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork && !a.ToString().StartsWith("169.254.", StringComparison.Ordinal)); + private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); From 55d36e5aa8d5bb0735d3ddb1669ca0e95be57e18 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:43:01 -0700 Subject: [PATCH 122/333] v1.8.4: Quote module arguments the Windows way and run suites from spaced paths The PowerShell module doubled no backslashes, so a value ending in one swallowed its closing quote. It now follows the Windows command-line rules, checked by round-tripping through CommandLineToArgvW on PowerShell 5.1 and 7. The focused-suite runner quotes the project path, so a checkout with a space in its path runs. --- Modules/KillerScan/KillerScan.psm1 | 26 +++++++++++++++++++++++--- tests/Module/ModuleContract.ps1 | 24 ++++++++++++++++++++++++ tests/Run-FocusedSuites.ps1 | 3 ++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Modules/KillerScan/KillerScan.psm1 b/Modules/KillerScan/KillerScan.psm1 index 6de33380..56492582 100644 --- a/Modules/KillerScan/KillerScan.psm1 +++ b/Modules/KillerScan/KillerScan.psm1 @@ -20,6 +20,28 @@ function Get-KsExe { throw 'KillerScan was not found. Open a terminal from KillerScan, or install it so killerscan.exe is on PATH.' } +# Quotes one argument the way Windows programs split their command line: backslashes are +# literal unless they come before a quote, so a run of them before a quote, or before the +# closing quote, is doubled. Without that, a path ending in a backslash would swallow the +# closing quote and run into the next argument. +function ConvertTo-KsArgument { + param([AllowEmptyString()][string]$Value) + + if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { return $Value } + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $slashes = 0 + foreach ($c in $Value.ToCharArray()) { + if ($c -eq '\') { $slashes++; continue } + if ($c -eq '"') { [void]$quoted.Append('\', $slashes * 2 + 1) } + elseif ($slashes -gt 0) { [void]$quoted.Append('\', $slashes) } + [void]$quoted.Append($c) + $slashes = 0 + } + if ($slashes -gt 0) { [void]$quoted.Append('\', $slashes * 2) } + $quoted.Append('"').ToString() +} + # Runs the app with stdout and stderr kept apart: stdout is the data, stderr is status # and diagnostics. Exit codes are 0 success, 1 failure, cancel or timeout, 2 bad usage # and 3 empty or not found. Codes listed in -Answer are results rather than failures @@ -31,9 +53,7 @@ function Invoke-KsText { $exe = Get-KsExe $info = New-Object System.Diagnostics.ProcessStartInfo $info.FileName = $exe - $info.Arguments = ($Arguments | ForEach-Object { - if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } - }) -join ' ' + $info.Arguments = ($Arguments | ForEach-Object { ConvertTo-KsArgument $_ }) -join ' ' $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.RedirectStandardOutput = $true diff --git a/tests/Module/ModuleContract.ps1 b/tests/Module/ModuleContract.ps1 index 368c66e5..754574db 100644 --- a/tests/Module/ModuleContract.ps1 +++ b/tests/Module/ModuleContract.ps1 @@ -115,6 +115,30 @@ try { $demoLocation = Get-Failure { scan -Demo -Location 'Anywhere' } Require ($null -ne $demoLocation -and $demoLocation.Exception.Data['ExitCode'] -eq 2) 'The real app refuses -Location with -Demo as a usage error.' + # Arguments survive the Windows command-line split exactly, trailing backslashes and quotes included. + if (-not ('KsArgv' -as [type])) { + Add-Type -TypeDefinition @' +using System; using System.Runtime.InteropServices; +public static class KsArgv { + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CommandLineToArgvW(string line, out int count); + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr p); + public static string[] Split(string line) { + int n; IntPtr a = CommandLineToArgvW(line, out n); + try { var r = new string[n]; for (int i = 0; i < n; i++) r[i] = Marshal.PtrToStringUni(Marshal.ReadIntPtr(a, i * IntPtr.Size)); return r; } + finally { LocalFree(a); } + } +} +'@ + } + $module = Get-Module KillerScan + $samples = @('plain', 'two words', 'C:\some folder\', 'say "hi"', 'x\"y z', 'end\\', '') + foreach ($sample in $samples) { + $quoted = & $module { param($v) ConvertTo-KsArgument $v } $sample + $split = [KsArgv]::Split('app.exe ' + $quoted) + Require ($split.Count -eq 2 -and $split[1] -ceq $sample) "Argument quoting round-trips [$sample] as $quoted." + } + $env:KS_EXE = New-FakeExe 'noisy' '[{"IpAddress":"192.0.2.1","OpenPorts":[]}]' 'Progress: 100%%' 0 $noisy = @(scan 192.0.2.1) Require ($noisy.Count -eq 1 -and $noisy[0].Ip -eq '192.0.2.1') 'Stderr status lines never mix into parsed stdout.' diff --git a/tests/Run-FocusedSuites.ps1 b/tests/Run-FocusedSuites.ps1 index 653fb356..3583145e 100644 --- a/tests/Run-FocusedSuites.ps1 +++ b/tests/Run-FocusedSuites.ps1 @@ -42,7 +42,8 @@ $failed = @() foreach ($flag in $entry.ci) { $watch = [System.Diagnostics.Stopwatch]::StartNew() $log = [System.IO.Path]::GetTempFileName() - $arguments = @('run', '--project', $project, '--framework', $Framework, '--configuration', $Configuration, + # Start-Process joins the list with plain spaces, so a checkout path with a space is quoted here. + $arguments = @('run', '--project', ('"' + $project + '"'), '--framework', $Framework, '--configuration', $Configuration, '--no-build', '--no-restore', '--', $flag) $process = Start-Process dotnet -ArgumentList $arguments -NoNewWindow -PassThru ` -RedirectStandardOutput $log -RedirectStandardError "$log.err" From e2b093cc1567bb50416e509fd23d7c8c87542a4b Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:43:02 -0700 Subject: [PATCH 123/333] v1.8.4: Roll back failed Location changes and keep unsaved history on reload Catalog changes apply only once saved, so a failed create, rename or delete is neither listed nor replayed later. A failed import move takes its entry off the list again, and customer and notes are saved with the entry. A terminal scan whose Location was deleted meanwhile does not recreate its folder. History baselines are chosen by time, not list position, and rereading history after a terminal scan keeps runs that could not be saved yet. --- Features/Cli/CliRunner.cs | 11 ++- Services/JsonPreferenceFile.cs | 14 ++++ Services/LocationService.cs | 24 +++++- Services/LocationTransfer.cs | 15 +++- Services/Locations.cs | 15 ++-- Services/ScanHistory.cs | 26 ++++-- Shell/TerminalScan.cs | 2 +- tests/SpeedTest.Tests/LocationTests.cs | 106 +++++++++++++++++++++++++ 8 files changed, 190 insertions(+), 23 deletions(-) diff --git a/Features/Cli/CliRunner.cs b/Features/Cli/CliRunner.cs index ee7c5e15..6ad4035e 100644 --- a/Features/Cli/CliRunner.cs +++ b/Features/Cli/CliRunner.cs @@ -664,8 +664,15 @@ private static int RunDevices(List positionals, Dictionary mutation, bool memoryOnly = false) return Save(); } + /// + /// Applies a change only if it reaches the file. Unlike , a failed + /// save leaves the value as it was and does not keep the change for a later retry, so + /// nothing half-done is shown now or written later. For stores where an entry that + /// is listed but not saved would mislead, such as the Locations catalog. + /// + internal bool Commit(Action mutation) + { + _pending.Add(mutation); + if (Save()) return true; + _pending.Remove(mutation); + return false; + } + internal bool Save() { try diff --git a/Services/LocationService.cs b/Services/LocationService.cs index b7d64095..17af88a1 100644 --- a/Services/LocationService.cs +++ b/Services/LocationService.cs @@ -119,15 +119,22 @@ public static bool Delete(LocationInfo info, out string? kept) public static byte[]? Export(LocationInfo info, bool includeUsernames) => Editable ? LocationTransfer.Export(Find(info.Id) ?? info, _catalog!.DirectoryFor(info), includeUsernames) : null; - public static bool Import(byte[] bytes, out LocationInfo? imported, out string? error) + /// + /// Adds a Location from a .ksloc file. With false, as when it + /// runs off the UI thread, the caller raises afterwards through + /// , because its handlers update the window. + /// + public static bool Import(byte[] bytes, out LocationInfo? imported, out string? error, bool notify = true) { imported = null; if (!Editable) { error = LoadError ?? "Locations are unavailable."; return false; } bool ok = LocationTransfer.Import(_catalog!, bytes, out imported, out error); - if (ok) Changed?.Invoke(null, EventArgs.Empty); + if (ok && notify) Changed?.Invoke(null, EventArgs.Empty); return ok; } + public static void NotifyChanged() => Changed?.Invoke(null, EventArgs.Empty); + /// Records a gateway MAC seen by a completed scan in this Location. public static void AddGatewayMac(LocationInfo info, string mac) { @@ -146,6 +153,19 @@ private static bool Notify(bool changed) return changed; } + /// + /// Whether a Location is still in the catalog on disk, read afresh, so a change made by + /// another window since this process loaded is seen. A catalog that cannot be read + /// counts as still there, because nothing proves it was deleted. + /// + public static bool StillExists(string id) + { + if (!Loaded || DemoData.Enabled || _catalog == null || !_catalog.Published) return true; + var fresh = new LocationCatalog(Root); + fresh.Load(string.Empty); + return fresh.LoadError != null || fresh.Items.Any(item => item.Id == id); + } + /// The current catalog entry for an ID, after renames and reloads. public static LocationInfo? Find(string id) => Items.FirstOrDefault(item => item.Id == id); } diff --git a/Services/LocationTransfer.cs b/Services/LocationTransfer.cs index 656c2152..a0f705f4 100644 --- a/Services/LocationTransfer.cs +++ b/Services/LocationTransfer.cs @@ -82,14 +82,18 @@ internal static bool Import(LocationCatalog catalog, byte[] bytes, out LocationI try { unreadable = LocationData.Open(new LocationInfo { Id = "import", Name = file.Name! }, staging).ReadError; } catch (Exception ex) { unreadable = ex.Message; } if (unreadable != null) { error = "This Location file is damaged. " + unreadable; return false; } - if (!catalog.Create(file.Name!, out var created) || created == null) + if (!catalog.Create(file.Name!, file.Customer ?? string.Empty, file.Notes ?? string.Empty, out var created) || created == null) { error = catalog.SaveError ?? catalog.LoadError ?? "The Location could not be added."; return false; } - Directory.Move(staging, catalog.DirectoryFor(created)); - catalog.SetCustomer(created.Id, file.Customer ?? string.Empty); - catalog.SetNotes(created.Id, file.Notes ?? string.Empty); + try { MoveDirectory(staging, catalog.DirectoryFor(created)); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Its files never arrived, so the entry comes off the list again. + catalog.Delete(created.Id, out _); + throw; + } imported = catalog.Items.FirstOrDefault(item => item.Id == created.Id) ?? created; error = null; return true; @@ -106,6 +110,9 @@ internal static bool Import(LocationCatalog catalog, byte[] bytes, out LocationI } } + /// Moves the staged folder into place. Tests replace it to make the move fail. + internal static Action MoveDirectory { get; set; } = Directory.Move; + private sealed class TransferFile { public string? Format { get; set; } diff --git a/Services/Locations.cs b/Services/Locations.cs index 39f072dd..b20754c7 100644 --- a/Services/Locations.cs +++ b/Services/Locations.cs @@ -215,7 +215,7 @@ private void Migrate(string unassignedName) } Directory.Move(staging, target); } - if (!_store.Change(value => + if (!_store.Commit(value => { if (existing.Count > 0 && !value.Locations.Any(item => item.Id == UnassignedId)) value.Locations.Add(new LocationInfo { Id = UnassignedId, Name = unassignedName }); @@ -230,12 +230,15 @@ private void Migrate(string unassignedName) public LocationData Open(LocationInfo info) => LocationData.Open(info, DirectoryFor(info)); - public bool Create(string name, out LocationInfo? created) + public bool Create(string name, out LocationInfo? created) => Create(name, string.Empty, string.Empty, out created); + + /// Adds a Location with its customer label and notes in one save, so it is never listed half-made. + public bool Create(string name, string customer, string notes, out LocationInfo? created) { created = null; if (LoadError != null) return false; - var info = new LocationInfo { Id = Guid.NewGuid().ToString("N"), Name = name.Trim() }; - if (!_store.Change(value => value.Locations.Add(info))) return false; + var info = new LocationInfo { Id = Guid.NewGuid().ToString("N"), Name = name.Trim(), Customer = customer.Trim(), Notes = notes }; + if (!_store.Commit(value => value.Locations.Add(info))) return false; created = _store.Value.Locations.FirstOrDefault(item => item.Id == info.Id) ?? info; return true; } @@ -248,7 +251,7 @@ public bool Delete(string id, out string? kept) { kept = null; if (LoadError != null || Items.FirstOrDefault(item => item.Id == id) is not { } info) return false; - if (!_store.Change(value => value.Locations.RemoveAll(item => item.Id == id))) return false; + if (!_store.Commit(value => value.Locations.RemoveAll(item => item.Id == id))) return false; string folder = DirectoryFor(info); if (!Directory.Exists(folder)) return true; string deleted = Path.Combine(LocationsDirectory, ".deleted"); @@ -288,7 +291,7 @@ public bool SetLastUsed(string id, string targets, ScanOptions options) => Update(id, item => { item.LastTargets = targets; item.LastOptions = options; }); private bool Update(string id, Action change) => - LoadError == null && _store.Change(value => + LoadError == null && _store.Commit(value => { if (value.Locations.FirstOrDefault(item => item.Id == id) is { } item) change(item); }); diff --git a/Services/ScanHistory.cs b/Services/ScanHistory.cs index 4acba010..efbd82e6 100644 --- a/Services/ScanHistory.cs +++ b/Services/ScanHistory.cs @@ -164,13 +164,24 @@ internal bool ApplyRetention(HistoryRetention policy, Func confirmRem return applied; } - public ScanComparison Compare(ScanHistoryEntry entry) + public ScanComparison Compare(ScanHistoryEntry entry) => Compare(entry, Baseline(entry)); + + // The latest earlier run that is a fair baseline, by when it ran. Runs adopted from a + // Temporary scan keep their own time but are added at the end, so list order is not time. + private ScanHistoryEntry? Baseline(ScanHistoryEntry current) => _entries + .Where(candidate => !ReferenceEquals(candidate, current) && candidate.ScannedAt < current.ScannedAt && candidate.IsBaselineFor(current)) + .OrderBy(candidate => candidate.ScannedAt) + .LastOrDefault(); + + /// + /// Rereads the file, such as after another process recorded a run, and keeps runs this + /// process could not save yet so a later retry still has them. Load starts over instead. + /// + public void Reload() { - int index = _entries.IndexOf(entry); - ScanHistoryEntry? previous = index > 0 - ? _entries.Take(index).LastOrDefault(candidate => candidate.IsBaselineFor(entry)) - : null; - return Compare(entry, previous); + if (MemoryOnly) return; + _archive.Load(DateTimeOffset.Now); + _entries = _archive.Entries; } public void Load() @@ -211,8 +222,7 @@ public ScanComparison Record(string caption, string targets, IEnumerable device.IpAddress)] }; - var previous = _entries.LastOrDefault(entry => entry.IsBaselineFor(current)); - var comparison = Compare(current, previous); + var comparison = Compare(current, Baseline(current)); if (MemoryOnly) _entries = _demoRetention.Retained(_entries.Concat([current]), current.ScannedAt); else { diff --git a/Shell/TerminalScan.cs b/Shell/TerminalScan.cs index bcd82324..83eacdf3 100644 --- a/Shell/TerminalScan.cs +++ b/Shell/TerminalScan.cs @@ -146,7 +146,7 @@ private async void RunTerminalScan(string target) { // Another process wrote history and Known devices; show what it saved. location.Devices.Load(); - location.History.Load(); + location.History.Reload(); RefreshHistoryList(); } terminal.WriteManaged(presentation.Result(devices)); diff --git a/tests/SpeedTest.Tests/LocationTests.cs b/tests/SpeedTest.Tests/LocationTests.cs index a2851890..7268458e 100644 --- a/tests/SpeedTest.Tests/LocationTests.cs +++ b/tests/SpeedTest.Tests/LocationTests.cs @@ -23,6 +23,11 @@ internal static void Run() UnreadableAfterMigration(Path.Combine(root, "stale")); ConcurrentMigration(Path.Combine(root, "concurrent")); LeftoverUnassigned(Path.Combine(root, "leftover")); + FailedCatalogChange(Path.Combine(root, "busy")); + FailedImportMove(Path.Combine(root, "move")); + DeletedDuringScan(Path.Combine(root, "deleted")); + BaselineByTime(Path.Combine(root, "time")); + ReloadKeepsUnsaved(Path.Combine(root, "reload")); Isolation(Path.Combine(root, "isolation")); KnownAndApproved(Path.Combine(root, "known")); CommandLine(Path.Combine(root, "cli")); @@ -184,6 +189,107 @@ private static void LeftoverUnassigned(string root) Require(File.ReadAllText(Path.Combine(kept, "marker.txt")) == "written after the earlier move", "The leftover folder is kept under .deleted."); } + // A catalog change that cannot be saved is not listed now and not replayed later. + private static void FailedCatalogChange(string root) + { + var catalog = new LocationCatalog(root); + catalog.Load("Unassigned"); + // Another window holding the catalog's save lock for longer than the 2 s wait. + string path = Path.GetFullPath(Path.Combine(root, "locations.json")); + using var hash = System.Security.Cryptography.SHA256.Create(); + string key = BitConverter.ToString(hash.ComputeHash(System.Text.Encoding.UTF8.GetBytes(path.ToUpperInvariant()))).Replace("-", ""); + var held = new System.Threading.ManualResetEventSlim(); + var release = new System.Threading.ManualResetEventSlim(); + var holder = new System.Threading.Thread(() => + { + using var mutex = new System.Threading.Mutex(false, "Global\\KillerScan.Preferences." + key); + mutex.WaitOne(); + held.Set(); + release.Wait(); + mutex.ReleaseMutex(); + }); + holder.Start(); + held.Wait(); + bool created = catalog.Create("Ghost", out _); + release.Set(); + holder.Join(); + Require(!created && catalog.Items.Count == 0, "A Location whose save failed is not listed."); + Require(catalog.Create("Real", out _) && catalog.Items.Select(item => item.Name).SequenceEqual(["Real"]), + "The failed change is not replayed by the next save."); + } + + // An import whose files cannot be moved into place leaves no Location behind. + private static void FailedImportMove(string root) + { + var catalog = new LocationCatalog(root); + catalog.Load("Unassigned"); + byte[] file = System.Text.Encoding.UTF8.GetBytes("{\"Format\":\"KillerScan.Location\",\"Version\":1,\"Name\":\"Moved\",\"Customer\":\"Acme\",\"Notes\":\"n\",\"Files\":{}}"); + var move = LocationTransfer.MoveDirectory; + try + { + LocationTransfer.MoveDirectory = (_, _) => throw new IOException("The folder is in use."); + Require(!LocationTransfer.Import(catalog, file, out _, out string? error) && error == "The folder is in use." && catalog.Items.Count == 0, + "A failed move takes the new entry off the list again."); + } + finally { LocationTransfer.MoveDirectory = move; } + Require(LocationTransfer.Import(catalog, file, out var imported, out _) && imported!.Customer == "Acme" && imported.Notes == "n", + "An import saves its customer and notes with the entry."); + } + + // A terminal scan checks its Location is still there before recording into it. + private static void DeletedDuringScan(string root) + { + string previous = LocationService.Root; + try + { + LocationService.Root = root; + LocationService.Load("Unassigned"); + Require(LocationService.Create("Site", out var site), "A Location is created."); + Require(LocationService.StillExists(site!.Id), "A Location in the catalog still exists."); + var otherWindow = new LocationCatalog(root); + otherWindow.Load("Unassigned"); + Require(otherWindow.Delete(site.Id, out _), "Another window deletes it."); + Require(!LocationService.StillExists(site.Id), "The deletion is seen without reloading this process."); + } + finally { LocationService.Root = previous; } + } + + // History compares with the latest earlier run by time, even after an older run is adopted. + private static void BaselineByTime(string root) + { + var history = new HistoryStore(Path.Combine(root, "history.json")); + history.Load(); + var temporary = LocationData.Temporary().History; + temporary.Record("10.3.0.0/24", "10.3.0.0/24", [Device("10.3.0.1")], ScanCompletion.Complete, new ScanOptions(), deep: false); + System.Threading.Thread.Sleep(20); + history.Record("10.3.0.0/24", "10.3.0.0/24", [Device("10.3.0.2")], ScanCompletion.Complete, new ScanOptions(), deep: false); + var newer = history.Entries.Last(); + history.Adopt(temporary.Entries.Single()); + System.Threading.Thread.Sleep(20); + var latest = history.Record("10.3.0.0/24", "10.3.0.0/24", [Device("10.3.0.2")], ScanCompletion.Complete, new ScanOptions(), deep: false); + Require(latest.Previous?.ScannedAt == newer.ScannedAt, "The baseline is the newest earlier run, not the one adopted last."); + Require(history.Compare(history.Entries.Last()).Previous?.ScannedAt == newer.ScannedAt, "Viewing the run later picks the same baseline."); + } + + // Rereading history after another process wrote keeps a run this process could not save. + private static void ReloadKeepsUnsaved(string root) + { + string path = Path.Combine(root, "history.json"); + var history = new HistoryStore(path); + history.Load(); + history.Record("10.4.0.0/24", "10.4.0.0/24", [Device("10.4.0.1")], ScanCompletion.Complete, new ScanOptions(), deep: false); + bool saved; + using (new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None)) + saved = history.Record("10.4.0.0/24", "10.4.0.0/24", [Device("10.4.0.2")], ScanCompletion.Complete, new ScanOptions(), deep: false).Saved; + Require(!saved && history.Entries.Count == 2, "A run that could not be saved is kept for a retry."); + history.Reload(); + Require(history.Entries.Count == 2, "Reloading keeps the unsaved run."); + Require(history.Retry(), "The kept run saves on retry."); + var reread = new HistoryStore(path); + reread.Load(); + Require(reread.Entries.Count == 2, "Both runs are in the file after the retry."); + } + private static void Isolation(string root) { var catalog = new LocationCatalog(root); From ba6cd477a9d7e2383933d698ed7b1bb769bd8d18 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 21 Oct 2026 16:43:03 -0700 Subject: [PATCH 124/333] v1.8.4: Tidy the Location banner, menus, import, topology keys and terminal input The suggestion banner reads the gateway from the ARP cache without sending anything, keeps up with Location and language changes, and is announced to screen readers with Switch first in the tab order. Menu details such as saved scan targets and adapter addresses are read as help, not as shortcuts. Import refuses files over 256 MB and runs off the UI thread. Topology arrow keys select the device they move to, Ctrl+I is in the shortcut list, refused terminal input beeps and says so, and a closed workspace releases its font hooks and notice timer. --- CHANGELOG.md | 14 +-- Controls/DetailMenuItem.cs | 34 +++++++ Controls/ScanOptionsMenu.cs | 2 +- Controls/ScanWorkspace.xaml | 13 +-- Controls/ScanWorkspace.xaml.cs | 21 ++++- Controls/ScanWorkspace/Location.cs | 86 +++++++++++++++--- Controls/ScanWorkspace/NetworkInfo.cs | 5 +- Controls/ScanWorkspace/TopologyKeyboard.cs | 11 ++- Shell/Shortcuts.cs | 1 + Shell/TerminalPanel.cs | 7 ++ Strings/bn.xaml | 2 + Strings/cs-CZ.xaml | 2 + Strings/de-DE.xaml | 2 + Strings/en-US.xaml | 2 + Strings/es.xaml | 2 + Strings/fr-FR.xaml | 2 + Strings/hu-HU.xaml | 2 + Strings/it-IT.xaml | 2 + Strings/ja-JP.xaml | 2 + Strings/kk-KZ.xaml | 2 + Strings/nb-NO.xaml | 2 + Strings/pl-PL.xaml | 2 + Strings/pt-BR.xaml | 2 + Strings/ru-RU.xaml | 2 + Strings/sl-SI.xaml | 2 + Strings/tr-TR.xaml | 2 + Strings/uk-UA.xaml | 2 + Strings/vi-VN.xaml | 2 + Strings/zh-CN.xaml | 2 + Strings/zh-TW.xaml | 2 + Terminal/TerminalControl.cs | 15 ++- docs/context-menu-shortcuts.md | 2 +- docs/locations.md | 4 +- scan-landing/shortcuts.generated.js | 2 +- tests/SpeedTest.Tests/AccessUiTests.cs | 19 ++++ .../SpeedTest.Tests/LocationWorkspaceTests.cs | 91 +++++++++++++++++++ .../SpeedTest.Tests/TopologyKeyboardTests.cs | 10 ++ 37 files changed, 338 insertions(+), 39 deletions(-) create mode 100644 Controls/DetailMenuItem.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5f95b0..5660c3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Scan history includes empty results and final automatic deep-profile results, with canceled or partial runs labeled. - HTTP fingerprints follow bounded redirects only on the scanned IP, preserving local panels without contacting or labeling another device. - SNMP fingerprints preserve long descriptions and reject malformed or mismatched response identities. -- Deep scans never send HTTP or TLS data to raw print ports (9100 to 9103 and 515), so a rescan cannot print junk pages. +- Deep scans never send HTTP or TLS data to raw print ports (9100 to 9103 and 515), so a rescan cannot print junk pages, including when a web page redirects to one of those ports. - HTTP fingerprints connect to the device directly, so a system or PAC proxy can no longer answer and have its page reported as the device. - NetBIOS names are accepted only from validated replies sent by the queried host. -- The PowerShell `scan` and `probe` commands return device objects instead of nothing, and failures, timeouts and usage errors throw with the app's message and exit code. +- The PowerShell `scan` and `probe` commands return device objects instead of nothing, and failures, timeouts and usage errors throw with the app's message and exit code. Arguments such as a folder path ending in a backslash reach the app intact. - The engine's scan APIs refuse malformed or oversized targets and a port concurrency below 1 before any network or DNS cache work, instead of reporting an empty scan or waiting forever. - Saving an export over an existing file keeps the old file intact if the save fails partway. This covers CSV, HTML, SVG, PNG and JPEG exports, history CSV, Keep Alive and terminal saves, and command-line `/export`. - A scan history file that cannot be read (damaged, locked or access denied) is reported at startup with its location and a Retry, and History says it could not be read instead of showing no scans. The file is never overwritten. @@ -39,9 +39,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - Stopping during MAC resolution returns promptly, skips queued lookups and keeps native work bounded across scans. - Terminal title sequences accept BEL and ESC-backslash terminators across split reads and preserve UTF-8 titles. - Pasting into the terminal drops control characters other than tab and line breaks, so pasted text can no longer end a bracketed paste early and run the rest as typed commands. -- A large paste into the terminal no longer freezes the window while the shell is busy. Input is written in the background in order, so a 16 MB paste returns at once instead of blocking for many seconds. +- A large paste into the terminal no longer freezes the window while the shell is busy. Input is written in the background in order, so a 16 MB paste returns at once instead of blocking for many seconds. Input the terminal cannot take, such as a paste over 64 MB, beeps and says so instead of vanishing. - Topology and Services no longer redraw everything for each device a scan finds. Updates are grouped, so a large scan stays responsive with either view open. -- Topology devices can be used from the keyboard. Arrow keys move between devices, Space selects and Ctrl+Space adds to the selection, Shift+arrow keys move a device, and screen readers announce each device and whether it is selected. Focus stays on the same device when the diagram updates. +- Topology devices can be used from the keyboard. Arrow keys move between devices and select the one they land on, so Enter and the device shortcuts act on it; Ctrl+arrow keys move without changing the selection, Space selects and Ctrl+Space adds to it, Shift+arrow keys move a device, and screen readers announce each device and whether it is selected. Focus stays on the same device when the diagram updates. - The device table's MAC column is never narrower than a full address in the current font, so a dragged or older saved width can no longer cut addresses off. - A device name lookup that never answers no longer holds up a scan. Each lookup gets three seconds, and stalled lookups are capped so they cannot pile up. - Device and Services column headers follow a language change, and HTML reports declare the app's language and have a translated title. (#5) (Thanks @MrCodeLabs) @@ -70,9 +70,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this - The first scan in a Location sets a baseline of Known devices without approving any. Later scans flag devices that are new since that baseline, and the device mark shows approved, new and known. - Saved scans belong to a Location and can be saved in place or under a new name. Picking a Location restores its last targets and settings without scanning. - Each Location has notes for the gateway, VLANs, quirks or who to call, shown in the Location button's tooltip. -- Export Location saves a Location to one `.ksloc` file to import on another PC. Remembered SSH usernames are left out unless you tick the box, and passwords are never stored. Import always adds a new Location and never runs or scans anything. -- When the network's gateway has been seen in a Location, a banner offers to switch to it. It never switches by itself, offers nothing when more than one Location matches beyond listing them, and Dismiss hides it until KillerScan restarts. -- Scan settings has a Network adapter choice. Picking an adapter sends every probe from it, so Ethernet, Wi-Fi and VPN adapters on the same PC no longer mix results. A bound scan reaches the adapter's own network and anything Windows already routes through it, such as hosts behind a VPN adapter; a target Windows would send out another adapter is refused with a message naming both. Saved scans keep their adapter, and if it is not connected KillerScan asks before using the adapter Windows chooses. Each run records the adapter it used, and HTML reports show the Location and adapter. +- Export Location saves a Location to one `.ksloc` file to import on another PC. Remembered SSH usernames are left out unless you tick the box, and passwords are never stored. Import always adds a new Location and never runs or scans anything. It reads the file in the background and refuses files over 256 MB. +- When the network's gateway has been seen in a Location, a banner offers to switch to it. It never switches by itself, offers nothing when more than one Location matches beyond listing them, and Dismiss hides it until KillerScan restarts. It reads the gateway from Windows' own ARP cache, so checking sends nothing, and screen readers announce it. +- Scan settings has a Network adapter choice. Picking an adapter sends every probe from it, so Ethernet, Wi-Fi and VPN adapters on the same PC no longer mix results. A bound scan reaches the adapter's own network and anything Windows already routes through it, such as hosts behind a VPN adapter; a target Windows would send out another adapter is refused with a message naming both. Saved scans keep their adapter, and if it is not connected KillerScan asks before using the adapter Windows chooses. A bound scan finds hosts only in its own adapter's ARP table. Each run records the adapter it used, and HTML reports show the Location and adapter. - Save results to Location moves a Temporary scan's history and any names you typed into a Location, without approving anything. - Manage Locations on the Location button opens a sidebar list of every Location, with the current one marked. Each can be switched to, renamed, given notes, exported or deleted. Deleting moves the Location's files aside to a deleted folder rather than erasing them. The Profiles section is now called Saved scans. - History lists the current Location's scans. Right-click the History button and choose Show all Locations to list every Location's scans, each labeled with its Location. diff --git a/Controls/DetailMenuItem.cs b/Controls/DetailMenuItem.cs new file mode 100644 index 00000000..200c0f8c --- /dev/null +++ b/Controls/DetailMenuItem.cs @@ -0,0 +1,34 @@ +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; + +namespace KillerScan.Controls +{ + /// + /// A menu item that shows a detail, such as a saved scan's target or an adapter's address, + /// in the right-hand column where shortcuts usually go. Screen readers would announce that + /// column as the item's keyboard shortcut, so this item reports no shortcut and reads the + /// detail as help text instead. + /// + internal sealed class DetailMenuItem : MenuItem + { + public DetailMenuItem() + { + // The app's MenuItem style is implicit, which does not reach a subclass by itself. + SetResourceReference(StyleProperty, typeof(MenuItem)); + } + + protected override AutomationPeer OnCreateAutomationPeer() => new DetailPeer(this); + + private sealed class DetailPeer(DetailMenuItem owner) : MenuItemAutomationPeer(owner) + { + protected override string GetAcceleratorKeyCore() => string.Empty; + + protected override string GetHelpTextCore() + { + string help = base.GetHelpTextCore(); + return help.Length > 0 ? help : ((MenuItem)Owner).InputGestureText ?? string.Empty; + } + } + } +} diff --git a/Controls/ScanOptionsMenu.cs b/Controls/ScanOptionsMenu.cs index c73c8165..23d96ff5 100644 --- a/Controls/ScanOptionsMenu.cs +++ b/Controls/ScanOptionsMenu.cs @@ -40,7 +40,7 @@ void Fill() var available = hooks.List(); foreach (var binding in available.GroupBy(b => b.InterfaceName).Select(g => g.First())) { - var item = new MenuItem + var item = new DetailMenuItem { Header = binding.InterfaceName, InputGestureText = binding.LocalAddress + "/" + binding.PrefixLength, diff --git a/Controls/ScanWorkspace.xaml b/Controls/ScanWorkspace.xaml index 065a2f2f..e52909ab 100644 --- a/Controls/ScanWorkspace.xaml +++ b/Controls/ScanWorkspace.xaml @@ -353,16 +353,17 @@ - + CornerRadius="{DynamicResource PanelCornerRadius}"> + + -
- - + +
KillerScan device list beside the scan history sidebar
A completed scan with IP addresses, hostnames, MAC vendors, device classifications and open ports in one sortable view, beside the scan history.
KillerScan network topology with the scan history sidebar collapsed
Topology draws the same devices as a map you can rearrange and tune, and exports the arranged view as a transparent PNG, a JPG or an HTML page with SVG.
KillerScan device list beside the scan history sidebar
A completed scan with addresses, hostnames, vendors, device types and open ports, beside the scan history.
KillerScan network topology with the scan history sidebar collapsed
Topology draws the same devices as a map you can rearrange and export.
-## Requirements - -- Windows 10 or 11 (x64) -- .NET Framework 4.8, included with Windows 10 version 1903 and later and with Windows 11, so there is normally nothing to install. All other dependencies are bundled inside the EXE. -- Run as admin for best ARP results on some networks +## Install -## Download - -- Prebuilt binary: -- Source (GPL3 corresponding source for this release): - -WinGet: +Windows 10 or 11 (x64) with .NET Framework 4.8, which Windows 10 version 1903 and later already include. Run as admin for the best ARP results on some networks. ```powershell winget install killerscan ``` -Chocolatey: - ```powershell choco install killerscan ``` -For managed removal, exit codes and elevated-update requirements, see [deployment and removal](docs/deployment.md). +Or download [KillerScan.exe](https://github.com/SteveTheKiller/KillerScan/releases/latest/download/KillerScan.exe) and run it. It offers to install for you, for all users, or not at all. The GPL source for this release is [KillerScan-1.8.3-src.zip](https://github.com/SteveTheKiller/KillerScan/releases/download/v1.8.3/KillerScan-1.8.3-src.zip). For managed removal and exit codes, see [deployment](docs/deployment.md). ## Build from source @@ -84,56 +51,18 @@ cd KillerScan dotnet publish -c Release ``` -Output lands in `bin/Release/net48/publish/`. The publish step produces a single Costura-bundled `KillerScan.exe` plus a versioned `KillerScan--src.zip` for GPL3 source distribution. - -Requires the .NET 8 SDK or later to build (even though the output targets .NET Framework 4.8). - -## Privacy and network connections - -KillerScan has no usage analytics or automatic scan-result uploads. Vendor identification and device classification run locally. It does make network connections: - -- Scans and diagnostics contact the selected targets and configured DNS services, which may be outside the LAN. -- Opening About checks GitHub for updates. An update downloads release files after confirmation. Vendor refresh downloads full lists from Wireshark and GitHub without sending scanned MAC addresses. -- Starting a speed test connects to `speed.killerscan.net` over HTTPS. Download and upload payloads are generated test data, not files, scan results, device names, or MAC addresses. Downloaded bytes are discarded from memory, never executed. The Worker counts and discards uploads and does not save test results. -- Cloudflare processes the public IP and ordinary request metadata. The Worker uses that IP for temporary rate limiting. This is not a guarantee of zero provider infrastructure or security logs. -- Opening a saved HTML report can fetch its logo from `scan.killertools.net`; the image request does not upload its device table. Terminal commands and links you open can contact other services. - -The website loads separate analytics from `koya.thekiller.net`; the no-usage-analytics statement above concerns the desktop app. See the [technical explanation](https://killerscan.net/technical.html#speedtest-network) for speed-test traffic, limits, and source links. - -## Speed-test development - -The default profile compares two, four, and eight concurrent HTTP transfers during nine seconds of warmup per direction, then runs a 15-second test. Upload keeps the connection count that improved measured throughput by at least 5%. Download always measures with all eight connections, because short warmup bursts do not predict sustained throughput. Request sizes adapt to the connection while staying within the deployed server limits. A 3 GiB budget per direction includes warmup and can shorten a very fast test; the terminal reports that limit. Final throughput uses payload bytes transferred during the timed phase. Upload bytes are counted as they are sent, but the test still requires a valid server acknowledgment. Server rate limits stop the test instead of adding retries to the timing window. - -The upload count includes payload successfully written to the HTTP transport before cutoff, including requests still in flight. Some of those bytes can remain buffered before the receiver reads them. A valid acknowledgment establishes that the endpoint completed an upload exchange; later sent bytes remain part of the rate even if their request is unacknowledged. Received and acknowledged totals are separate quantities. - -Latency is HTTP round-trip time to the selected endpoint, including server processing. Idle latency is the median of five samples after an unmeasured connection warmup. Jitter is the mean absolute difference between consecutive samples. Loaded latency is sampled on a separate connection during each measured phase. These measurements describe that route and server, and do not claim packet loss or universal ISP capacity. - -When you start a speed test, the app connects to `https://speed.killerscan.net/`, the dedicated [KillerScan endpoint](server/speedtest/README.md) hosted on Cloudflare Workers. Users do not need an account or server setup. Deployment credentials are kept outside the source and app. - -Run the retained tests on Windows with the .NET SDK and Node.js 22 or newer: - -```powershell -dotnet build tests/SpeedTest.Tests/SpeedTest.Tests.csproj -c Release -./tests/SpeedTest.Tests/bin/Release/net48/SpeedTest.Tests.exe --worker -node --test server/speedtest/worker.test.mjs -``` - -The release script also adds `--internet` to verify the deployed KillerScan service before publishing or signing. That check uses the app's full budget of up to 3 GiB in each direction, including warmup. The normal checks above stay on loopback; passing them alone does not verify the deployed service. - -## Translations - -UI strings live in `Strings/` (one XAML `ResourceDictionary` per locale). To add or improve a language, see [TRANSLATING.md](TRANSLATING.md). Missing keys fall back to English. - -## Changelog +Needs the .NET 8 SDK or later. Output lands in `bin/Release/net48/publish/`. Speed-test internals and tests are in [docs/speed-test.md](docs/speed-test.md). -See [CHANGELOG.md](CHANGELOG.md). +## Privacy -## How classification works +KillerScan has no usage analytics and never uploads scan results. Vendor lookup and classification run locally. It connects out only when you scan or diagnose, open About (update check), refresh vendors, run a speed test against `speed.killerscan.net`, or open an HTML report, which fetches its logo. The speed test runs through Cloudflare, which sees your public IP, and sends only generated test data. The website has its own analytics; this statement covers the app. The [technical page](https://killerscan.net/technical.html#speedtest-network) has the details. -The classifier accumulates points from every signal (open ports, OUI vendor, hostname keywords, HTTP title, SSH banner, TLS subject, SNMP description, NetBIOS name, TTL, mDNS service types, SSDP SERVER string) and picks the highest-scoring type above a threshold. This replaces brittle first-match port rules and avoids false positives like "my coworker's laptop is a hypervisor because port 2179 is open." +## More -See `Services/NetworkScanner.cs` -> `ClassifyDevice` for the scoring table. For a full technical breakdown of how KillerScan works end to end - the scan pipeline, vendor resolution, and the classifier - see . For how to drive each view, every tool and every shortcut, see . +- [CHANGELOG.md](CHANGELOG.md) +- [TRANSLATING.md](TRANSLATING.md) for adding or improving a language +- [How it works](https://killerscan.net/technical.html): the scan pipeline, vendor resolution and the classifier ## License -GPLv3. See [LICENSE](LICENSE). If you fork, modify, or redistribute KillerScan, your version must also be released under GPLv3 with source available. No exceptions for commercial rebrands. +GPLv3. See [LICENSE](LICENSE). Forks and redistributions must stay GPLv3 with source available. diff --git a/docs/speed-test.md b/docs/speed-test.md new file mode 100644 index 00000000..ff90f397 --- /dev/null +++ b/docs/speed-test.md @@ -0,0 +1,19 @@ +# Speed test + +The default profile compares two, four, and eight concurrent HTTP transfers during nine seconds of warmup per direction, then runs a 15-second test. Upload keeps the connection count that improved measured throughput by at least 5%. Download always measures with all eight connections, because short warmup bursts do not predict sustained throughput. Request sizes adapt to the connection while staying within the deployed server limits. A 3 GiB budget per direction includes warmup and can shorten a very fast test; the terminal reports that limit. Final throughput uses payload bytes transferred during the timed phase. Upload bytes are counted as they are sent, but the test still requires a valid server acknowledgment. Server rate limits stop the test instead of adding retries to the timing window. + +The upload count includes payload successfully written to the HTTP transport before cutoff, including requests still in flight. Some of those bytes can remain buffered before the receiver reads them. A valid acknowledgment establishes that the endpoint completed an upload exchange; later sent bytes remain part of the rate even if their request is unacknowledged. Received and acknowledged totals are separate quantities. + +Latency is HTTP round-trip time to the selected endpoint, including server processing. Idle latency is the median of five samples after an unmeasured connection warmup. Jitter is the mean absolute difference between consecutive samples. Loaded latency is sampled on a separate connection during each measured phase. These measurements describe that route and server, and do not claim packet loss or universal ISP capacity. + +When you start a speed test, the app connects to `https://speed.killerscan.net/`, the dedicated [KillerScan endpoint](../server/speedtest/README.md) hosted on Cloudflare Workers. Users do not need an account or server setup. Deployment credentials are kept outside the source and app. + +Run the retained tests on Windows with the .NET SDK and Node.js 22 or newer: + +```powershell +dotnet build tests/SpeedTest.Tests/SpeedTest.Tests.csproj -c Release +./tests/SpeedTest.Tests/bin/Release/net48/SpeedTest.Tests.exe --worker +node --test server/speedtest/worker.test.mjs +``` + +The release script also adds `--internet` to verify the deployed KillerScan service before publishing or signing. That check uses the app's full budget of up to 3 GiB in each direction, including warmup. The normal checks above stay on loopback; passing them alone does not verify the deployed service. From 1fb7461c6395e06789eb897943de3e9bc975fa24 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 28 Oct 2026 17:21:41 -0700 Subject: [PATCH 264/333] Adapter preference test sets up its Location through LocationService, where the app records the last adapter --- tests/SpeedTest.Tests/LocationWorkspaceTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/SpeedTest.Tests/LocationWorkspaceTests.cs b/tests/SpeedTest.Tests/LocationWorkspaceTests.cs index 19450a84..41b67213 100644 --- a/tests/SpeedTest.Tests/LocationWorkspaceTests.cs +++ b/tests/SpeedTest.Tests/LocationWorkspaceTests.cs @@ -302,10 +302,12 @@ private static void AdapterPreference(string root) try { NetworkPlatform.Current = platform; - var catalog = new LocationCatalog(root); - catalog.Load("Unassigned"); - catalog.Create("Site", out var info); - var site = catalog.Open(info!); + // Through LocationService, as the app does: the last-used adapter is recorded there, + // so a private catalog would never see it. + LocationService.Root = root; + LocationService.Load("Unassigned"); + Require(LocationService.Create("Site", out var info), "A Location is created."); + var site = LocationService.Get(info!); var scanner = new SiteScanner(); using var workspace = new ScanWorkspace("192.168.1.0/24", false, scanner); var ethernet = new ScanBinding("Ethernet", System.Net.IPAddress.Parse("192.168.1.5"), 24); From 33a9e98b071ebe7ae8b2ffb7a03b7b44085aa0c8 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 28 Oct 2026 17:52:41 -0700 Subject: [PATCH 265/333] HTML reports carry their own logo, so opening one contacts nothing (U006) --- CHANGELOG.md | 1 + KillerScan.csproj | 1 + README.md | 2 +- Resources/report-logo.svg | 41 ++++++++++++ Services/ReportExport.cs | 31 ++++++--- Services/ReportLogoColors.cs | 46 +++++++++++++ tests/SpeedTest.Tests/ReportLogoTests.cs | 39 +++++++---- tools/Build-ReportLogo.ps1 | 83 ++++++++++++++++++++++++ 8 files changed, 221 insertions(+), 23 deletions(-) create mode 100644 Resources/report-logo.svg create mode 100644 Services/ReportLogoColors.cs create mode 100644 tools/Build-ReportLogo.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index eb177127..73828923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,7 @@ Locations keep each customer site separate, scan settings choose which checks ru - About groups settings in two columns and can hide shortcut hints in menus and tooltips without disabling keyboard shortcuts. Hover tooltips clear large pointers while preserving custom placement. ### Changed +- HTML reports carry their own logo in every theme and accent, so opening a report no longer contacts killerscan.net. - Confirmation prompts, including the install prompt, use the family dialog frame shared with KillerShell, and dragging a pinned place in the file dialog shows the plain arrow. - Scan profiles from earlier versions become saved scans, each keeping its name, target and settings, inside one Location named Saved scans. Load one and use Save scan to put it into its own Location. Nothing new can be saved into Saved scans, and it removes itself once it is empty. - Scan is the app's largest button: the filled accent button with its label in the wordmark's lettering. diff --git a/KillerScan.csproj b/KillerScan.csproj index bcc6f578..5c0d6946 100644 --- a/KillerScan.csproj +++ b/KillerScan.csproj @@ -82,6 +82,7 @@ + diff --git a/README.md b/README.md index f2033a7e..5fbb6041 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Needs the .NET 8 SDK or later. Output lands in `bin/Release/net48/publish/`. Spe ## Privacy -KillerScan has no usage analytics and never uploads scan results. Vendor lookup and classification run locally. It connects out only when you scan or diagnose, open About (update check), refresh vendors, run a speed test against `speed.killerscan.net`, or open an HTML report, which fetches its logo. The speed test runs through Cloudflare, which sees your public IP, and sends only generated test data. The website has its own analytics; this statement covers the app. The [technical page](https://killerscan.net/technical.html#speedtest-network) has the details. +KillerScan has no usage analytics and never uploads scan results. Vendor lookup and classification run locally. It connects out only when you scan or diagnose, open About (update check), refresh vendors, or run a speed test against `speed.killerscan.net`. HTML reports carry their own logo, so opening one contacts nothing. The speed test runs through Cloudflare, which sees your public IP, and sends only generated test data. The website has its own analytics; this statement covers the app. The [technical page](https://killerscan.net/technical.html#speedtest-network) has the details. ## More diff --git a/Resources/report-logo.svg b/Resources/report-logo.svg new file mode 100644 index 00000000..b43e38ba --- /dev/null +++ b/Resources/report-logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Services/ReportExport.cs b/Services/ReportExport.cs index 40291976..ff57bd40 100644 --- a/Services/ReportExport.cs +++ b/Services/ReportExport.cs @@ -22,10 +22,21 @@ namespace KillerScan.Services ///
internal static class ReportExport { - /// Where the report loads its wordmark from: the same per-theme and per-accent SVGs - /// the website uses. The report still works fully without them (text wordmark fallback), so - /// they can fail to load safely. - private const string LogoBase = "https://killerscan.net/brand/killerscan-logo-"; + /// The report's logo, carried inside the report so opening one contacts nothing. + /// One copy of the website's artwork with its colors as CSS variables (--lg0 to --lg4); + /// ReportLogoColors holds each theme and accent's colors. Both come from + /// tools\Build-ReportLogo.ps1. Empty if the resource is missing, which leaves the text + /// wordmark. + private static readonly Lazy LogoSvg = new(() => + { + using var stream = typeof(ReportExport).Assembly.GetManifestResourceStream("KillerScan.ReportLogo.svg"); + if (stream == null) return string.Empty; + using var reader = new System.IO.StreamReader(stream); + return reader.ReadToEnd().Trim(); + }); + + private static string LogoTable() => "{" + string.Join(",", ReportLogoColors.Variants.Select(v => + "'" + v.Key + "':[" + string.Join(",", v.Value.Select(c => "'" + c + "'")) + "]")) + "}"; /// Comma-separated devices, IP-ordered, every field quoted. /// Resource lookup that survives having no Application - the CLI builds reports @@ -117,7 +128,7 @@ internal static string BuildSnapshotHtml(IEnumerable devices, str sb.AppendLine(".wrap{max-width:1100px;margin:0 auto}"); sb.AppendLine(".topbar{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:8px}"); sb.AppendLine(".brand{display:flex;align-items:center;gap:10px;min-height:38px}"); - sb.AppendLine(".logo{height:36px;display:block}"); + sb.AppendLine(".logo svg{height:36px;width:auto;display:block}.logo.noshadow .lgshadow{filter:none}.logo.nooutline .lgoutline{display:none}"); sb.AppendLine(".wordmark{font-family:Consolas,'Courier New',monospace;font-size:27px;font-weight:700;letter-spacing:.5px}"); sb.AppendLine(".wordmark .k{color:var(--text)}.wordmark .s{color:var(--accent)}"); sb.AppendLine(".switchers{display:flex;flex-direction:column;gap:6px;align-items:flex-end}"); @@ -145,9 +156,10 @@ internal static string BuildSnapshotHtml(IEnumerable devices, str sb.AppendLine("@media(max-width:640px){body{padding:12px}.wordmark{font-size:22px}th,td{padding:8px 10px;font-size:12px}}"); sb.AppendLine("
"); - // Header: logo (if hosted) falls back to a Consolas wordmark - Killer in text color, Scan in accent. + // Header: the embedded logo in the theme's colors, or the Consolas wordmark (Killer in text color, + // Scan in accent) for a theme without one, such as 98SE. sb.AppendLine("
"); - sb.AppendLine(""); + sb.AppendLine(""); sb.AppendLine("KillerScan"); sb.AppendLine("
"); sb.AppendLine($"
{Esc(L("Str_Rep_Theme", "Theme"))}
"); @@ -230,8 +242,9 @@ internal static string BuildSnapshotHtml(IEnumerable devices, str ]) + "];"); sb.AppendLine("var sw=document.getElementById('themesw');"); sb.AppendLine("var CUSTOM_ACCENT={dark:1,light:1,black:1};"); - sb.AppendLine($"var LOGO='{LogoBase}',THEMED={{blood:1,greed:1,cyanotic:1,ectoplasm:1,decay:1,malaise:1,sepulchre:1,delirium:1,mourning:1}},ACC_NAMES={{'#DD504B':'red','#E8962C':'orange','#EAD900':'yellow','#1EA54C':'green','#1FB8A8':'teal','#50AEE8':'blue','#B982E3':'purple','#FF52C9':'magenta'}};"); - sb.AppendLine("function updateLogo(){var t=(document.documentElement.className||'').replace('theme-',''),img=document.getElementById('logo'),wm=document.getElementById('wm'),src=null;if(THEMED[t])src=LOGO+t+'.svg';else if(CUSTOM_ACCENT[t]){var a=null;try{a=localStorage.getItem('ksAccent')}catch(e){}src=LOGO+t+'-'+(ACC_NAMES[String(a||'').toUpperCase()]||'green')+'.svg';}if(!src){img.style.display='none';img.removeAttribute('src');wm.style.display='';return;}if(img.getAttribute('src')!==src)img.src=src;}"); + sb.AppendLine("var LOGOS=" + (LogoSvg.Value.Length == 0 ? "{}" : LogoTable()) + ";"); + sb.AppendLine($"var THEMED={{blood:1,greed:1,cyanotic:1,ectoplasm:1,decay:1,malaise:1,sepulchre:1,delirium:1,mourning:1}},ACC_NAMES={{'#DD504B':'red','#E8962C':'orange','#EAD900':'yellow','#1EA54C':'green','#1FB8A8':'teal','#50AEE8':'blue','#B982E3':'purple','#FF52C9':'magenta'}};"); + sb.AppendLine("function updateLogo(){var t=(document.documentElement.className||'').replace('theme-',''),logo=document.getElementById('logo'),wm=document.getElementById('wm'),key=null;if(THEMED[t])key=t;else if(CUSTOM_ACCENT[t]){var a=null;try{a=localStorage.getItem('ksAccent')}catch(e){}key=t+'-'+(ACC_NAMES[String(a||'').toUpperCase()]||'green');}var c=key&&LOGOS[key];if(!c){logo.style.display='none';wm.style.display='';return;}for(var i=0;i<5;i++)logo.style.setProperty('--lg'+i,c[i]);logo.className='logo'+(c[5]==='1'?'':' noshadow')+(c[4]==='none'?' nooutline':'');logo.style.display='';wm.style.display='none';}"); sb.AppendLine("function setTheme(t){document.documentElement.className='theme-'+t;try{localStorage.setItem('ksTheme',t)}catch(e){}var k=sw.children;for(var i=0;iPer logo variant: the five slot colors (--lg0 to --lg4), then "1" for the drop shadow.
+ internal static readonly IReadOnlyDictionary Variants = new Dictionary + { + ["black-blue"] = new[] { "#ffffff", "#14467f", "#9ecbff", "#298dff", "none", "1" }, + ["black-green"] = new[] { "#ffffff", "#007f33", "#8cffba", "#00ff66", "none", "1" }, + ["black-magenta"] = new[] { "#ffffff", "#7f155e", "#ff9fe1", "#ff2bbd", "none", "1" }, + ["black-orange"] = new[] { "#ffffff", "#7f4805", "#ffcd90", "#ff910a", "none", "1" }, + ["black-purple"] = new[] { "#ffffff", "#5c147f", "#df9eff", "#b829ff", "none", "1" }, + ["black-red"] = new[] { "#ffffff", "#7f1414", "#ff9e9e", "#ff2929", "none", "1" }, + ["black-teal"] = new[] { "#ffffff", "#057f73", "#90fff4", "#0affe7", "none", "1" }, + ["black-yellow"] = new[] { "#ffffff", "#7f7500", "#fff68c", "#ffeb00", "none", "1" }, + ["blood"] = new[] { "#ffffff", "#7c644f", "#fbe6d3", "#f8c99e", "none", "1" }, + ["cyanotic"] = new[] { "#ffffff", "#706a4d", "#f1ebd1", "#e0d49a", "none", "1" }, + ["dark-blue"] = new[] { "#ffffff", "#285774", "#b0daf4", "#50aee8", "none", "1" }, + ["dark-green"] = new[] { "#ffffff", "#0f5226", "#99d6ae", "#1ea54c", "none", "1" }, + ["dark-magenta"] = new[] { "#ffffff", "#7f2964", "#ffb1e6", "#ff52c9", "none", "1" }, + ["dark-orange"] = new[] { "#ffffff", "#744b16", "#f4cfa0", "#e8962c", "none", "1" }, + ["dark-purple"] = new[] { "#ffffff", "#5c4171", "#dfc6f2", "#b982e3", "none", "1" }, + ["dark-red"] = new[] { "#ffffff", "#6e2825", "#efb0ae", "#dd504b", "none", "1" }, + ["dark-teal"] = new[] { "#ffffff", "#0f5c54", "#9adfd7", "#1fb8a8", "none", "1" }, + ["dark-yellow"] = new[] { "#ffffff", "#756c00", "#f5ed8c", "#ead900", "none", "1" }, + ["decay"] = new[] { "#ffffff", "#5c553e", "#dfd8c4", "#b8aa7c", "none", "1" }, + ["delirium"] = new[] { "#ffffff", "#6e4200", "#efc88c", "#dd8500", "none", "1" }, + ["ectoplasm"] = new[] { "#ffffff", "#756c00", "#f5ed8c", "#ead900", "none", "1" }, + ["greed"] = new[] { "#ffffff", "#706a4d", "#f1ebd1", "#e0d49a", "none", "1" }, + ["light-blue"] = new[] { "#000000", "#0c3047", "#97b7cc", "#18608e", "none", "0" }, + ["light-green"] = new[] { "#000000", "#0d2f10", "#98b69a", "#1b5e20", "none", "0" }, + ["light-magenta"] = new[] { "#000000", "#530038", "#d68cbe", "#a60070", "none", "0" }, + ["light-orange"] = new[] { "#000000", "#633807", "#e5bf93", "#c7710f", "none", "0" }, + ["light-purple"] = new[] { "#000000", "#2d0b48", "#b496cd", "#5a1690", "none", "0" }, + ["light-red"] = new[] { "#000000", "#490d0d", "#ce9797", "#931a1a", "none", "0" }, + ["light-teal"] = new[] { "#000000", "#06413f", "#92c6c4", "#0d827e", "none", "0" }, + ["light-yellow"] = new[] { "#000000", "#796200", "#f9e48c", "#f2c500", "#5a5a5a", "0" }, + ["malaise"] = new[] { "#ffffff", "#7f3748", "#ffbecd", "#ff6f91", "none", "1" }, + ["mourning"] = new[] { "#ffffff", "#7f3748", "#ffbecd", "#ff6f91", "none", "1" }, + ["sepulchre"] = new[] { "#ffffff", "#275554", "#afd8d7", "#4faaa8", "none", "1" }, + }; + } +} diff --git a/tests/SpeedTest.Tests/ReportLogoTests.cs b/tests/SpeedTest.Tests/ReportLogoTests.cs index cc37f252..3da51422 100644 --- a/tests/SpeedTest.Tests/ReportLogoTests.cs +++ b/tests/SpeedTest.Tests/ReportLogoTests.cs @@ -11,24 +11,27 @@ internal static class ReportLogoTests public static Task Run() { string html = ReportExport.BuildHtml([new NetworkDevice { IpAddress = "192.0.2.10" }], "192.0.2.0/24", "dark"); + // The logo travels inside the report, so opening one contacts nothing (U006). + if (html.Contains("killerscan.net/brand") || html.Contains("
+ internal static readonly IReadOnlyDictionary Variants = new Dictionary + { +$($lines -join "`n") + }; + } +} +"@ +[IO.File]::WriteAllText((Join-Path $repo 'Services\ReportLogoColors.cs'), $cs.Replace("`r`n", "`n") + "`n", [Text.UTF8Encoding]::new($false)) +"Wrote $($rows.Count) variants; template $([Math]::Round($template.Length / 1KB)) KB." From 06d62e18e011d00811049ce5aa942f06a56f6205 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 28 Oct 2026 17:56:48 -0700 Subject: [PATCH 266/333] Per-monitor DPI awareness through the family app manifest --- CHANGELOG.md | 1 + KillerScan.csproj | 2 ++ app.manifest | 31 +++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 app.manifest diff --git a/CHANGELOG.md b/CHANGELOG.md index 73828923..d9d4403b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,7 @@ Locations keep each customer site separate, scan settings choose which checks ru - About groups settings in two columns and can hide shortcut hints in menus and tooltips without disabling keyboard shortcuts. Hover tooltips clear large pointers while preserving custom placement. ### Changed +- KillerScan is per-monitor DPI aware, so text and icons stay sharp when the window moves between monitors with different scaling, instead of being stretched by Windows. - HTML reports carry their own logo in every theme and accent, so opening a report no longer contacts killerscan.net. - Confirmation prompts, including the install prompt, use the family dialog frame shared with KillerShell, and dragging a pinned place in the file dialog shows the plain arrow. - Scan profiles from earlier versions become saved scans, each keeping its name, target and settings, inside one Location named Saved scans. Load one and use Save scan to put it into its own Location. Nothing new can be saved into Saved scans, and it removes itself once it is empty. diff --git a/KillerScan.csproj b/KillerScan.csproj index 5c0d6946..2f014b66 100644 --- a/KillerScan.csproj +++ b/KillerScan.csproj @@ -11,6 +11,8 @@ enable true Resources\ks-icon.ico + + app.manifest 1.9.0 1.9.0.0 1.9.0.0 diff --git a/app.manifest b/app.manifest new file mode 100644 index 00000000..69841dca --- /dev/null +++ b/app.manifest @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + PerMonitorV2, PerMonitor + true/PM + + + + From b5a25d9837890b54e030ddd833fd1dcaab4e866a Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 28 Oct 2026 18:35:30 -0700 Subject: [PATCH 267/333] Topology spaces by its widest card, and the keyboard map has arrow keys --- CHANGELOG.md | 2 ++ Controls/ScanWorkspace/Topology.cs | 11 +++++++++++ Controls/ScanWorkspace/TopologySettings.cs | 9 +++++++-- Shell/KeyboardMap.cs | 7 +++++-- tests/SpeedTest.Tests/ReleaseUiTests.cs | 2 +- tests/SpeedTest.Tests/TopologyTests.cs | 6 ++++-- 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d4403b..0cf3785a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this Locations keep each customer site separate, scan settings choose which checks run, Slovenian joins the languages, and updates, installs and network probes are hardened throughout. ### Fixed +- Topology cards with long names no longer overlap their neighbors or hang off the edge of the map; every row and ring is spaced for its widest card. +- The F1 keyboard map shows the arrow keys, so Alt+Shift+Up and Alt+Shift+Down (tabs at the top or bottom) appear on it. - The tray icon's menu opens from the pointer upward, out of the notification area, the same way in every Killer app. A short menu no longer covers the tray icons and a taller one no longer floats about 40 pixels above them. - Self-update checks the downloaded and staged copies for the publisher's signature inside the helper, keeps every parent folder pinned while it publishes, refuses redirected paths and malformed requests, and offers only stable releases. - The close button on dialogs now works from the keyboard and screen readers, the window menu opens at the right spot while the window is maximized, and the file dialog's folder tree no longer jumps back to an older folder after a quick change of folder. diff --git a/Controls/ScanWorkspace/Topology.cs b/Controls/ScanWorkspace/Topology.cs index 1853bc86..e995e0ce 100644 --- a/Controls/ScanWorkspace/Topology.cs +++ b/Controls/ScanWorkspace/Topology.cs @@ -119,7 +119,18 @@ private void RefreshTopology() TopologyRefreshes++; // Rebuilding replaces every node, so a focused node's device gets focus back after. bool hadFocus = TopologyCanvas.IsKeyboardFocusWithin; + // Lay out with the default slot first. If a card measured larger than its slot, lay + // out again with the largest card as the slot, so every row and ring has room for it. + _topologyCardSize = default; BuildTopology(); + var cards = TopologyCanvas.Children.OfType().ToList(); + var largest = new Size(cards.Count == 0 ? 0 : cards.Max(card => card.Width), + cards.Count == 0 ? 0 : cards.Max(card => card.Height)); + if (largest.Width > TopologyNodeWidth + 0.5 || largest.Height > TopologyNodeHeight + 0.5) + { + _topologyCardSize = largest; + BuildTopology(); + } RestoreTopologyFocus(hadFocus); } diff --git a/Controls/ScanWorkspace/TopologySettings.cs b/Controls/ScanWorkspace/TopologySettings.cs index 846a50a9..89389833 100644 --- a/Controls/ScanWorkspace/TopologySettings.cs +++ b/Controls/ScanWorkspace/TopologySettings.cs @@ -29,10 +29,15 @@ public partial class ScanWorkspace private double TopologyScale => double.Parse(_topologyOptions["NodeScale"], CultureInfo.InvariantCulture) / 100; private double TopologyFontSize => double.Parse(_topologyOptions["FontSize"], CultureInfo.InvariantCulture); - private double TopologyNodeWidth => 126 * TopologyScale; + private double TopologyNodeWidth => Math.Max(126 * TopologyScale, _topologyCardSize.Width); // Just the two lines, the 3px ring and 2px of air: no fixed floor, so a card only grows // when the topology font does. - private double TopologyNodeHeight => Math.Ceiling(TopologyFontSize * 2.66 + 7); + private double TopologyNodeHeight => Math.Max(Math.Ceiling(TopologyFontSize * 2.66 + 7), _topologyCardSize.Height); + + /// The largest card the last build measured. Cards size to their text, so a long + /// name is wider than the default slot; the layout spaces by this so none overlap or leave + /// the canvas (RefreshTopology). + private Size _topologyCardSize; private bool TopologyHorizontal => _topologyOptions["Orientation"] == "Horizontal"; private void LoadTopologySettings() diff --git a/Shell/KeyboardMap.cs b/Shell/KeyboardMap.cs index 54d0d9c4..03f135a0 100644 --- a/Shell/KeyboardMap.cs +++ b/Shell/KeyboardMap.cs @@ -53,9 +53,11 @@ private static readonly (string Id, string Cap, double Width)[][] KeyboardRows = ("Semi", ";", 1), ("Quote", "'", 1), ("Enter", "Enter", 2.2)], [("Shift", "Shift", 2.3), ("Z", "Z", 1), ("X", "X", 1), ("C", "C", 1), ("V", "V", 1), ("B", "B", 1), ("N", "N", 1), ("M", "M", 1), ("Comma", ",", 1), ("Period", ".", 1), - ("Slash", "/", 1), ("RShift", "Shift", 2.7)], + ("Slash", "/", 1), ("RShift", "Shift", 2.7), + ("", "", 1.6), ("Up", "↑", 1)], [("Ctrl", "Ctrl", 1.5), ("Win", "Win", 1.2), ("Alt", "Alt", 1.5), ("Space", "", 6.8), - ("RAlt", "Alt", 1.5), ("Delete", "Del", 1), ("Menu", "Menu", 1), ("RCtrl", "Ctrl", 1.5)] + ("RAlt", "Alt", 1.5), ("Delete", "Del", 1), ("Menu", "Menu", 1), ("RCtrl", "Ctrl", 1.5), + ("", "", 0.6), ("Left", "←", 1), ("Down", "↓", 1), ("Right", "→", 1)] ]; private const double KeyboardUnit = 42; @@ -136,6 +138,7 @@ private static bool TryParseGesture(string gesture, out KbLayer layer, out strin "Tab" => "Tab", "Menu" => "Menu", "Delete" => "Delete", + "Up" or "Down" or "Left" or "Right" => key, "\\" => "BSl", _ when key.Length == 1 && char.IsDigit(key[0]) => "D" + key, _ when key.Length == 1 => key.ToUpperInvariant(), diff --git a/tests/SpeedTest.Tests/ReleaseUiTests.cs b/tests/SpeedTest.Tests/ReleaseUiTests.cs index 54717a1b..d72aad4f 100644 --- a/tests/SpeedTest.Tests/ReleaseUiTests.cs +++ b/tests/SpeedTest.Tests/ReleaseUiTests.cs @@ -471,7 +471,7 @@ private static void CheckTopologySettings(KillerScan.Controls.ScanWorkspace work Require(bounds[i].Left >= 0 && bounds[i].Top >= 0 && bounds[i].Right <= canvas.Width + 0.1 && bounds[i].Bottom <= canvas.Height + 0.1, "All topology boxes stay within the canvas."); for (int j = i + 1; j < bounds.Length; j++) - Require(!bounds[i].IntersectsWith(bounds[j]), "Automatic topology boxes do not overlap."); + Require(!bounds[i].IntersectsWith(bounds[j]), "Automatic topology boxes do not overlap: " + pattern + "/" + orientation + "/" + scale + "/" + size + " " + bounds[i] + " and " + bounds[j] + "."); } foreach (var node in canvas.Children.OfType()) { diff --git a/tests/SpeedTest.Tests/TopologyTests.cs b/tests/SpeedTest.Tests/TopologyTests.cs index 3b09dea2..38e198c4 100644 --- a/tests/SpeedTest.Tests/TopologyTests.cs +++ b/tests/SpeedTest.Tests/TopologyTests.cs @@ -207,7 +207,9 @@ void Theme(string theme, string accent) var deviceNode = canvas.Children.OfType().First(n => n.Tag is NetworkDevice); var click = new MouseButtonEventArgs(Mouse.PrimaryDevice, 0, MouseButton.Left) { RoutedEvent = UIElement.MouseLeftButtonDownEvent, Source = deviceNode }; deviceNode.RaiseEvent(click); - Require(ReferenceEquals(grid.SelectedItem, deviceNode.Tag) && deviceNode.BorderThickness.Left == 2, "Selection works while zoomed."); + // A selected card wears the accent ring (TopologyNode.Selected); the stripe width never changes. + Require(ReferenceEquals(grid.SelectedItem, deviceNode.Tag) && deviceNode is KillerScan.Controls.TopologyNode { Selected: true }, + "Selection works while zoomed."); deviceNode.ReleaseMouseCapture(); var point = deviceNode.TranslatePoint(new Point(deviceNode.Width / 2, deviceNode.Height / 2), root); DependencyObject? hit = VisualTreeHelper.HitTest(root, point)?.VisualHit; @@ -294,7 +296,7 @@ private static void CheckBoxes(Canvas canvas) var boxes = canvas.Children.OfType().Select(Bounds).ToArray(); foreach (var box in boxes) Require(box.Left >= 0 && box.Top >= 0 && box.Right <= canvas.Width + 0.01 && box.Bottom <= canvas.Height + 0.01, - "Node stays within the canvas."); + "Node stays within the canvas: box " + box + ", canvas " + canvas.Width + "x" + canvas.Height + "."); for (int i = 0; i < boxes.Length; i++) for (int j = i + 1; j < boxes.Length; j++) Require(!boxes[i].IntersectsWith(boxes[j]), "Automatic nodes do not overlap."); } From f40de4223453ee712a4a3a4c86390747ab57e467 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 28 Oct 2026 18:35:32 -0700 Subject: [PATCH 268/333] UI suites follow the 2026-10-26 layout: icon-only Scan settings, tab-side pane corners, no toolbar overflow menu, family sort menu, trimmed shortcut list --- tests/SpeedTest.Tests/HistoryUiTests.cs | 18 ++++++++++++++---- tests/SpeedTest.Tests/MenuIconUiTests.cs | 14 +++++--------- tests/SpeedTest.Tests/ScanOptionsUiChecks.cs | 6 ++++-- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/tests/SpeedTest.Tests/HistoryUiTests.cs b/tests/SpeedTest.Tests/HistoryUiTests.cs index 2847600d..4bde8e86 100644 --- a/tests/SpeedTest.Tests/HistoryUiTests.cs +++ b/tests/SpeedTest.Tests/HistoryUiTests.cs @@ -471,9 +471,17 @@ void Compare(string name, int width, bool open) Require(ReferenceEquals(heading.Template, headerTemplate) && Equals(heading.Background, headerBackground) && Equals(heading.Foreground, headerForeground) && heading.Padding == standardHeader.Padding, context + ": actual rendered column headers use the standard Devices style."); - Require(frame.CornerRadius == radius && surface.Clip.FillContains(new Point(0, 0)) == clipAtCorner && - ReferenceEquals(surface, frame.Child) && ReferenceEquals(history.Parent, Field(window, "_workspaceBody")), - context + ": history uses the same rounded table frame and clipping as Devices."); + // The tab-side corners follow the active tab (ApplyPaneCorners squares the end tab's + // corner), so only the corners away from the tabs must match Devices. + var now = frame.CornerRadius; + bool sameRadius = (bool)Field(window, "_tabsAtBottom")! + ? now.TopLeft == radius.TopLeft && now.TopRight == radius.TopRight + : now.BottomLeft == radius.BottomLeft && now.BottomRight == radius.BottomRight, + sameClip = surface.Clip.FillContains(new Point(0, 0)) == clipAtCorner, + framed = ReferenceEquals(surface, frame.Child), hosted = ReferenceEquals(history.Parent, Field(window, "_workspaceBody")); + Require(sameRadius && sameClip && framed && hosted, + context + ": history uses the same rounded table frame and clipping as Devices (radius " + sameRadius + + ", clip " + sameClip + ", frame child " + framed + ", host " + hosted + ": " + history.Parent?.GetType().Name + ")."); Require(supplemental.Visibility == Visibility.Visible && Bounds(summary).Bottom <= Bounds(frame).Top && Bounds(toggle).Bottom <= Bounds(frame).Top && Bounds(identity).Bottom <= Bounds(summary).Top && Bounds(summary).Right <= Bounds(toggle).Left && !frame.IsAncestorOf(summary) && !frame.IsAncestorOf(toggle), @@ -682,7 +690,9 @@ private static void CheckCatalog(MainWindow window, Application app) Require(app.FindResource(key) is string text && text.Length > 0, "Shortcut description resolves: " + key); } Call(window, "BuildShortcutRows"); Call(window, "BuildKeyboardMap"); - Require(rows.Length >= 70, "The list and map include the added contextual shortcuts."); + // A floor against an accidentally truncated catalog. The Profiles and toolbar-style rows left + // with those features on 2026-10-26, so it sits below the count from before then. + Require(rows.Length >= 60, "The list and map include the added contextual shortcuts: " + rows.Length + "."); } private static void CheckHistorySettingsMenu(MainWindow window, Application app, string output) diff --git a/tests/SpeedTest.Tests/MenuIconUiTests.cs b/tests/SpeedTest.Tests/MenuIconUiTests.cs index 05d92ba2..7392778f 100644 --- a/tests/SpeedTest.Tests/MenuIconUiTests.cs +++ b/tests/SpeedTest.Tests/MenuIconUiTests.cs @@ -69,9 +69,6 @@ private static void CheckMenuIcons(MainWindow window, Assembly assembly, string // Build the actual dynamic menus without needing a visible desktop popup or any action. menus.Add("HistoryExport", (ContextMenu)Call(history, "CreateExportMenu", (FrameworkElement)scan.FindName("ExportButton"))!); var root = (FrameworkElement)window.Content; Render(root, 640, 780); Call(window, "FitToolbarViews"); - var overflow = (ContextMenu)Call(window, "CreateToolbarOverflowMenu")!; - Require(overflow.Items.Count > 0, "A narrow toolbar creates its actual overflow menu."); - menus.Add("Overflow", overflow); foreach (var shortcut in new[] { (Key.Apps, ModifierKeys.None), (Key.F10, ModifierKeys.Shift) }) { Require(history.HandleShortcut(shortcut.Item1, shortcut.Item2), "History supports keyboard menu opening."); @@ -125,13 +122,12 @@ private static void CheckMenuLevel(ItemsControl owner, UIElement[] rows, string int codepoint = char.ConvertToUtf32(glyph.Text, 0); Require(font.CharacterToGlyphMap.ContainsKey(codepoint), label + " exists in the installed font: U+" + codepoint.ToString("X4")); } - // The file picker's sort menu keeps its own wider gutter; every other menu uses the + // Every menu, the file picker's sort menu included since the KillerUI sync, uses the // family MenuItem, whose icon presenter is IconPresenter (KillerScan's old one was "ico"). - bool picker = name == "Sort"; var presenter = (ContentPresenter)(item.Template.FindName("IconPresenter", item) ?? item.Template.FindName("ico", item)); Require(presenter.Visibility == Visibility.Visible && icon.ActualWidth > 0 && icon.ActualHeight > 0, label + " renders its icon."); var grid = (Grid)presenter.Parent; - Require(grid.ColumnDefinitions[0].ActualWidth == (picker ? 18 : 16), label + " keeps a fixed icon gutter."); + Require(grid.ColumnDefinitions[0].ActualWidth == 16, label + " keeps a fixed icon gutter."); if (item.HasItems) { var arrow = (TextBlock)(item.Template.FindName("SubArrow", item) ?? item.Template.FindName("arrow", item)); @@ -146,14 +142,14 @@ private static void CheckMenuLevel(ItemsControl owner, UIElement[] rows, string var check = (TextBlock)(item.Template.FindName("Check", item) ?? item.Template.FindName("CheckGlyph", item)); Require(check.Text == "\uE73E" && check.Visibility == Visibility.Visible && presenter.Visibility == Visibility.Collapsed, label + " replaces the icon with a checkmark."); - Require(item.ActualHeight == rowHeight && grid.ColumnDefinitions[0].ActualWidth == (picker ? 18 : 16), + Require(item.ActualHeight == rowHeight && grid.ColumnDefinitions[0].ActualWidth == 16, label + " preserves spacing when checked."); item.SetCurrentValue(MenuItem.IsCheckedProperty, false); } item.SetCurrentValue(UIElement.IsEnabledProperty, true); typeof(MenuItem).GetProperty("IsHighlighted")!.SetValue(item, true); surface.UpdateLayout(); - var expected = (Brush)app.FindResource(picker ? "TextBrush" : "MenuHoverTextBrush"); + var expected = (Brush)app.FindResource("MenuHoverTextBrush"); if (icon is TextBlock highlighted) Require(highlighted.Foreground.ToString() == expected.ToString(), label + " follows the highlighted foreground."); typeof(MenuItem).GetProperty("IsHighlighted")!.SetValue(item, false); @@ -161,7 +157,7 @@ private static void CheckMenuLevel(ItemsControl owner, UIElement[] rows, string // The family MenuItem dims its row border (MenuBd, which holds the icon); the picker // dims the whole item. Either way the glyph dims with its row. double dimmed = item.Opacity * ((item.Template.FindName("MenuBd", item) as UIElement)?.Opacity ?? 1); - Require(Math.Abs(dimmed - (picker ? 0.45 : 0.4)) < 0.001, label + " dims disabled glyphs with its row."); + Require(Math.Abs(dimmed - 0.4) < 0.001, label + " dims disabled glyphs with its row."); Require(item.InputGestureText == gesture, label + " retains its shortcut text."); item.SetCurrentValue(UIElement.IsEnabledProperty, enabled[item]); item.SetCurrentValue(MenuItem.IsCheckedProperty, checks[item]); diff --git a/tests/SpeedTest.Tests/ScanOptionsUiChecks.cs b/tests/SpeedTest.Tests/ScanOptionsUiChecks.cs index 00d1176a..6589f0ae 100644 --- a/tests/SpeedTest.Tests/ScanOptionsUiChecks.cs +++ b/tests/SpeedTest.Tests/ScanOptionsUiChecks.cs @@ -20,8 +20,10 @@ internal static void Run(ScanWorkspace workspace) "Scan settings immediately follows Scan."); Require(!string.IsNullOrWhiteSpace(AutomationProperties.GetName(button!)), "Scan settings has an accessible name."); - Require(button!.Content is Panel content && content.Children.OfType().Any(t => t.Text.Length > 1), - "Scan settings has a visible text label beside its icon."); + // Icon-only since the scan bar moved into the title bar; the name is its tooltip. + Require(button!.Content is Panel content && content.Children.OfType().Any(t => t.Text == "") + && button.ToolTip != null, + "Scan settings shows its gear icon with a tooltip."); button.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent)); var menu = button.ContextMenu; // The offscreen host has no presentation source to raise the popup's opened event. From 71dbd10caab365c06145a0c6a91cab02a6ab45d2 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 28 Oct 2026 18:51:38 -0700 Subject: [PATCH 269/333] History menu test checks the KillerUI flyout anchor rule --- tests/SpeedTest.Tests/HistoryUiTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/SpeedTest.Tests/HistoryUiTests.cs b/tests/SpeedTest.Tests/HistoryUiTests.cs index 4bde8e86..a0bd09bf 100644 --- a/tests/SpeedTest.Tests/HistoryUiTests.cs +++ b/tests/SpeedTest.Tests/HistoryUiTests.cs @@ -700,7 +700,10 @@ private static void CheckHistorySettingsMenu(MainWindow window, Application app, var icon = (Button)window.FindName("HistoryButton"); var menu = icon.ContextMenu; Call(window, "HistorySettingsMenu_Opening", icon, null); - Require(ReferenceEquals(menu.PlacementTarget, icon) && menu.CustomPopupPlacementCallback != null && menu.HorizontalOffset == 0 && menu.VerticalOffset == 0, + // FlyoutPlacement.Attach (KillerUI) targets the unscaled root and places the menu from the + // button through its callback, so the anchor is the button whatever opened the menu. + Require(menu.Placement == System.Windows.Controls.Primitives.PlacementMode.Custom && menu.PlacementTarget != null + && menu.CustomPopupPlacementCallback != null && menu.HorizontalOffset == 0 && menu.VerticalOffset == 0, "Mouse and native keyboard menu opening use the history button anchor, never the pointer."); // Rail flyouts are placed by the kit's FlyoutPlacement: against the window's unscaled root // (so app size cannot shift them), beside the rail, at their button's height but never From f233501cda853f7fa93a64e2884c2696fbd32338 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 28 Oct 2026 19:21:02 -0700 Subject: [PATCH 270/333] Locations list draws its rows in the theme's text color --- Shell/MainWindow.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shell/MainWindow.xaml b/Shell/MainWindow.xaml index be30bbb0..45d4b112 100644 --- a/Shell/MainWindow.xaml +++ b/Shell/MainWindow.xaml @@ -442,7 +442,7 @@ current Location carries a check. Saved scans live in the picker, not here (LocationsSidebar.cs). --> From f643aa6ceb2a1c71724e882a1b026f152615422a Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Wed, 28 Oct 2026 19:43:28 -0700 Subject: [PATCH 271/333] Fixes from the 1.9.0 fix-diff review: saved-scan rename, pending history runs, in-app SSH path, zero masks, Locations list keys and menu, import rename target, New Location gating, topology slot, keyboard map, WinGet hash check --- .github/workflows/winget-release.yml | 4 +++ CHANGELOG.md | 5 ++++ Controls/ScanWorkspace/Location.cs | 25 ++++++++++++++----- Controls/ScanWorkspace/Topology.cs | 4 ++- Engine/Network/LocalNetwork.cs | 2 ++ Services/HistoryArchive.cs | 3 +++ Services/LocationService.cs | 6 +++-- Services/ScanHistory.cs | 2 +- Services/ScanProfiles.cs | 18 ++++++++++++++ Shell/KeyboardMap.cs | 4 +-- Shell/MainWindow.xaml | 37 +++++++++++++++------------- Shell/Shortcuts.cs | 3 +++ Shell/Workspace.cs | 8 +++++- Strings/bn.xaml | 1 + Strings/cs-CZ.xaml | 1 + Strings/de-DE.xaml | 1 + Strings/en-US.xaml | 1 + Strings/es.xaml | 1 + Strings/fr-FR.xaml | 1 + Strings/hu-HU.xaml | 1 + Strings/it-IT.xaml | 1 + Strings/ja-JP.xaml | 1 + Strings/kk-KZ.xaml | 1 + Strings/nb-NO.xaml | 1 + Strings/pl-PL.xaml | 1 + Strings/pt-BR.xaml | 1 + Strings/ru-RU.xaml | 1 + Strings/sl-SI.xaml | 1 + Strings/tr-TR.xaml | 1 + Strings/uk-UA.xaml | 1 + Strings/vi-VN.xaml | 1 + Strings/zh-CN.xaml | 1 + Strings/zh-TW.xaml | 1 + 33 files changed, 111 insertions(+), 30 deletions(-) diff --git a/.github/workflows/winget-release.yml b/.github/workflows/winget-release.yml index 6100e969..5245ba00 100644 --- a/.github/workflows/winget-release.yml +++ b/.github/workflows/winget-release.yml @@ -140,6 +140,10 @@ jobs: $installer = Join-Path $dir 'SteveTheKiller.KillerScan.installer.yaml' if (-not (Test-Path $installer)) { Write-Error "komac did not write $installer"; exit 1 } $text = Get-Content $installer -Raw + # komac downloads the installer again on its own; the hash it wrote must be the copy + # checked above, or an asset swapped in between would be submitted unchecked. + $listedSha = [regex]::Match($text, '(?m)^\s*InstallerSha256:\s*(\S+)').Groups[1].Value + if ($listedSha -ne $hash) { Write-Error "komac's installer hash $listedSha is not the verified $hash."; exit 1 } # KillerScan installs itself machine-wide with /silent and registers its own uninstall # entry, so it is submitted as an exe installer. komac copies the diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf3785a..1de73ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this Locations keep each customer site separate, scan settings choose which checks run, Slovenian joins the languages, and updates, installs and network probes are hardened throughout. ### Fixed +- Renaming a saved scan keeps it, keeps its Deep Scan setting, and refuses a name another saved scan already uses instead of replacing that scan. +- A scan run whose save failed is no longer written to history twice, and deleting it stays deleted. A renamed run keeps its name when it is saved to a Location. +- SSH in the built-in terminal starts Windows' own ssh.exe by its full path, never a file of that name sitting beside a portable copy. +- An adapter reporting a 0.0.0.0 mask no longer becomes the network to scan; the next adapter is used. +- The Locations list answers Enter, F2, Delete and the menu key itself instead of passing them to the device table. - Topology cards with long names no longer overlap their neighbors or hang off the edge of the map; every row and ring is spaced for its widest card. - The F1 keyboard map shows the arrow keys, so Alt+Shift+Up and Alt+Shift+Down (tabs at the top or bottom) appear on it. - The tray icon's menu opens from the pointer upward, out of the notification area, the same way in every Killer app. A short menu no longer covers the tray icons and a taller one no longer floats about 40 pixels above them. diff --git a/Controls/ScanWorkspace/Location.cs b/Controls/ScanWorkspace/Location.cs index e47bccca..72948d58 100644 --- a/Controls/ScanWorkspace/Location.cs +++ b/Controls/ScanWorkspace/Location.cs @@ -424,6 +424,9 @@ private MenuItem BuildSaveScanItem() /// which is what an un-nicknamed scan shows. private void RenameSavedScan(ScanProfile profile) { + // The store is taken before the dialog, so the rename lands in the Location the menu + // showed even if this tab moves while the dialog is open. + var store = _location.SavedScans; var dialog = new InputDialog(Loc("Str_History_Rename"), profile.Target, Loc("Str_Rename_Label"), HasNickname(profile) ? profile.Name : string.Empty, Loc("Str_Rename_Save"), Loc("Str_Btn_Cancel")) { Owner = Window.GetWindow(this) }; dialog.ShowDialog(); @@ -431,10 +434,11 @@ private void RenameSavedScan(ScanProfile profile) string name = dialog.Value.Trim(); if (name.Length == 0) name = profile.Target; if (name == profile.Name) return; - var store = _location.SavedScans; - bool saved = store.SaveEdited(name, profile.Target, profile.Options, profile.Adapter ?? string.Empty) - && store.Delete(profile); - PersistenceFeedback.Check(saved, store.Retry, () => store.SaveError); + if (!store.Rename(profile, name, out bool taken)) + { + if (taken) StatusText.Text = string.Format(Loc("Str_Loc_ScanNameTaken"), name); + else PersistenceFeedback.Check(false, store.Retry, () => store.SaveError); + } LocationChanged?.Invoke(this, EventArgs.Empty); } @@ -639,7 +643,14 @@ internal void DeleteLocation(LocationInfo info) /// Switches to a Location chosen in the Locations sidebar. internal bool OpenLocation(LocationInfo info) => SetLocation(LocationService.Get(info)); - internal void NewLocation() => CreateLocation(); + /// New Location from the sidebar or Ctrl+Alt+S, with the same gates as the menu + /// item: not while scanning (it could not be entered), in demo mode or read-only. + internal void NewLocation() + { + if (IsScanning) { StatusText.Text = Loc("Str_Loc_Busy"); return; } + if (DemoMode || !LocationService.Editable) return; + CreateLocation(); + } /// The largest .ksloc file Import reads. Tests lower it. internal static long MaxImportBytes { get; set; } = 256L * 1024 * 1024; @@ -682,11 +693,13 @@ internal async System.Threading.Tasks.Task ImportLocationFrom(string path) return; } LocationService.NotifyChanged(); + // The tab may refuse to move (a scan started meanwhile, or unsaved results kept), so + // the rename below names the imported Location, never whichever one the tab is in. SetLocation(LocationService.Get(imported)); StatusText.Text = string.Format(Loc("Str_Loc_Imported"), imported.Name); // Same name as an existing Location is allowed, so offer a rename straight away. if (LocationService.Items.Count(item => string.Equals(item.Name, imported.Name, StringComparison.CurrentCultureIgnoreCase)) > 1) - RenameLocation(); + RenameLocation(imported); } /// Shows why an import failed. Tests capture the message without a dialog. diff --git a/Controls/ScanWorkspace/Topology.cs b/Controls/ScanWorkspace/Topology.cs index e995e0ce..580c2872 100644 --- a/Controls/ScanWorkspace/Topology.cs +++ b/Controls/ScanWorkspace/Topology.cs @@ -123,7 +123,9 @@ private void RefreshTopology() // out again with the largest card as the slot, so every row and ring has room for it. _topologyCardSize = default; BuildTopology(); - var cards = TopologyCanvas.Children.OfType().ToList(); + // Device cards only: the network card in the middle shows the whole target text, which + // can be several ranges long, and has a place of its own rather than a slot. + var cards = TopologyCanvas.Children.OfType().Where(card => card.Tag is NetworkDevice).ToList(); var largest = new Size(cards.Count == 0 ? 0 : cards.Max(card => card.Width), cards.Count == 0 ? 0 : cards.Max(card => card.Height)); if (largest.Width > TopologyNodeWidth + 0.5 || largest.Height > TopologyNodeHeight + 0.5) diff --git a/Engine/Network/LocalNetwork.cs b/Engine/Network/LocalNetwork.cs index f293aae6..36e3a606 100644 --- a/Engine/Network/LocalNetwork.cs +++ b/Engine/Network/LocalNetwork.cs @@ -124,6 +124,8 @@ internal sealed class Candidate if ((b & (1 << bit)) != 0) prefix++; else goto done; done: + // A 0.0.0.0 mask is no network at all, so the next adapter gets its turn. + if (prefix == 0) continue; return new LocalNet { diff --git a/Services/HistoryArchive.cs b/Services/HistoryArchive.cs index a926d0cb..6f0ab6e3 100644 --- a/Services/HistoryArchive.cs +++ b/Services/HistoryArchive.cs @@ -130,6 +130,8 @@ internal bool Remove(ScanHistoryEntry entry) if (kept.Count == Entries.Count) return false; Persist(kept, Policy); Entries = kept; + // The pending runs are in the file now; keeping them would write them twice. + _pending.Clear(); SaveError = null; return true; }); @@ -153,6 +155,7 @@ internal bool Rename(ScanHistoryEntry entry, string name) if (match == null) return false; match.Name = name; Persist(Entries, Policy); + _pending.Clear(); SaveError = null; return true; }); diff --git a/Services/LocationService.cs b/Services/LocationService.cs index b89d75b8..e97a63af 100644 --- a/Services/LocationService.cs +++ b/Services/LocationService.cs @@ -121,9 +121,11 @@ private static void MigrateSavedScans() private static void ForgetLearnedGateways() { if (!Editable || App.GetSetting("GatewaysFromSavesOnly") == "1") return; + bool cleared = true; foreach (var item in Items.Where(item => item.GatewayMacs.Count > 0).ToList()) - _catalog!.ClearGatewayMacs(item.Id); - App.SetSetting("GatewaysFromSavesOnly", "1"); + cleared &= _catalog!.ClearGatewayMacs(item.Id); + // A clear that failed (busy catalog, write error) is tried again on the next start. + if (cleared) App.SetSetting("GatewaysFromSavesOnly", "1"); } /// The data for a Location, shared by every tab that has it open. diff --git a/Services/ScanHistory.cs b/Services/ScanHistory.cs index d8363722..14ff5a4e 100644 --- a/Services/ScanHistory.cs +++ b/Services/ScanHistory.cs @@ -264,7 +264,7 @@ public bool Adopt(ScanHistoryEntry entry) var copy = new ScanHistoryEntry { ScannedAt = entry.ScannedAt, Target = entry.Target, TargetKey = entry.TargetKey, Options = entry.Options, - Deep = entry.Deep, Completion = entry.Completion, Adapter = entry.Adapter, Devices = [.. entry.Devices] + Deep = entry.Deep, Completion = entry.Completion, Adapter = entry.Adapter, Name = entry.Name, Devices = [.. entry.Devices] }; if (MemoryOnly) { _entries = _demoRetention.Retained(_entries.Concat([copy]), DateTimeOffset.Now); return true; } bool saved = _archive.Append(copy, DateTimeOffset.Now); diff --git a/Services/ScanProfiles.cs b/Services/ScanProfiles.cs index c272db2e..24af8c6a 100644 --- a/Services/ScanProfiles.cs +++ b/Services/ScanProfiles.cs @@ -89,6 +89,24 @@ public bool SaveEdited(string name, string target, ScanOptions options, string a }); }, MemoryOnly); + /// + /// Renames one saved scan in place, keeping everything else it holds (Deep Scan included). + /// A name only differing in case is a plain rename. A name another saved scan already uses + /// is refused rather than replacing that scan; says so. + /// + public bool Rename(ScanProfile profile, string name, out bool taken) + { + bool collision = Items.Any(item => !string.Equals(item.Name, profile.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)); + taken = collision; + if (collision) return false; + return _store.Change(items => + { + var found = items.FirstOrDefault(item => string.Equals(item.Name, profile.Name, StringComparison.OrdinalIgnoreCase)); + if (found != null) found.Name = name; + }, MemoryOnly); + } + public bool Delete(ScanProfile profile) => _store.Change(items => items.RemoveAll(item => string.Equals(item.Name, profile.Name, StringComparison.OrdinalIgnoreCase)), MemoryOnly); diff --git a/Shell/KeyboardMap.cs b/Shell/KeyboardMap.cs index 03f135a0..928ba73c 100644 --- a/Shell/KeyboardMap.cs +++ b/Shell/KeyboardMap.cs @@ -54,7 +54,7 @@ private static readonly (string Id, string Cap, double Width)[][] KeyboardRows = [("Shift", "Shift", 2.3), ("Z", "Z", 1), ("X", "X", 1), ("C", "C", 1), ("V", "V", 1), ("B", "B", 1), ("N", "N", 1), ("M", "M", 1), ("Comma", ",", 1), ("Period", ".", 1), ("Slash", "/", 1), ("RShift", "Shift", 2.7), - ("", "", 1.6), ("Up", "↑", 1)], + ("", "", 2.6), ("Up", "↑", 1)], // over Down: the bottom row is a key longer (Del) [("Ctrl", "Ctrl", 1.5), ("Win", "Win", 1.2), ("Alt", "Alt", 1.5), ("Space", "", 6.8), ("RAlt", "Alt", 1.5), ("Delete", "Del", 1), ("Menu", "Menu", 1), ("RCtrl", "Ctrl", 1.5), ("", "", 0.6), ("Left", "←", 1), ("Down", "↓", 1), ("Right", "→", 1)] @@ -311,7 +311,7 @@ private void SetKbLayer(KbLayer layer) /// this file wires in BuildKeyboardMap. private void KbSyncLayerFromModifiers() { - if (ShortcutMapHost.Visibility != Visibility.Visible) return; + if (!ShortcutMapHost.IsVisible) return; // stays Visible inside a closed overlay var m = Keyboard.Modifiers; var layer = m.HasFlag(ModifierKeys.Control) && m.HasFlag(ModifierKeys.Shift) ? KbLayer.CtrlShift : m.HasFlag(ModifierKeys.Control) && m.HasFlag(ModifierKeys.Alt) ? KbLayer.CtrlAlt diff --git a/Shell/MainWindow.xaml b/Shell/MainWindow.xaml index 45d4b112..748fa5aa 100644 --- a/Shell/MainWindow.xaml +++ b/Shell/MainWindow.xaml @@ -445,11 +445,31 @@ Background="Transparent" Foreground="{DynamicResource TextBrush}" BorderThickness="0" Margin="14,0,5,0" Visibility="Collapsed" HorizontalContentAlignment="Stretch" ScrollViewer.HorizontalScrollBarVisibility="Disabled" MouseDoubleClick="LocationList_DoubleClick" PreviewKeyDown="LocationList_KeyDown"> + + + + + + + + + + + + + + + + + + - - + + @@ -184,7 +187,7 @@
KillerTerminal -
winget install killerscan
+
winget install --id SteveTheKiller.KillerScan --exact
choco install killerscan
Source on GitHub @@ -199,7 +202,7 @@
-

What it is for

Walking into a site you have never seen. Scan the subnet and you have the router, the switches, the hypervisors, the NAS, the printers and the cameras before anyone hands you documentation.


Chasing an intermittent drop. Put the gateway and the device in Keep Alive, watch which one actually loses packets, and paste the result straight into the ticket.


Asked what changed. Scan history compares this run against the last one of the same target and names what appeared, what went missing and what opened a port.


Run it from a USB stick without installing the app. Scan results stay local unless you export or share them. Bug reports and requests go on the GitHub issues page.


Read the story behind it on the About page.

+

What it is for

Walking into a site you have never seen. Scan a subnet to identify reachable routers, switches, hypervisors, NAS devices, printers and cameras before you have network documentation.


Chasing an intermittent drop. Put the gateway and the device in Keep Alive, watch which one actually loses packets, and paste the result straight into the ticket.


Asked what changed. Scan history compares runs of the same target on the same network and shows devices that appeared, disappeared or changed open ports.


Run it from a USB stick without installing the app. Scan results stay local unless you export or share them. Bug reports and requests go on the GitHub issues page.


Read the story behind it on the About page.

Built from scratch

KillerScan is written in C# and WPF for .NET Framework 4.8. Its dependencies are bundled in one .exe; Windows must provide .NET Framework 4.8, and the embedded terminal needs Windows 10 version 1809 or later.

The bundled MAC vendor database covers MA-L, MA-M and MA-S prefixes and resolves vendors offline.

Discovery combines ARP, parallel ICMP probes and a TCP fallback for hosts that ignore ping. mDNS and SSDP add local discovery information.

The Technical page explains the scanner and device classification.

Fully featured

  • ARP cache plus a parallel ping sweep, then a second ARP read to catch ICMP-blockers
  • @@ -208,7 +211,7 @@
  • IEEE OUI vendor ID - MA-L, MA-M and MA-S
  • Weighted classifier: routers, NAS, printers, cameras, IoT, phones and more
  • Topology, Keep Alive and an embedded terminal beside the device list
  • -
  • Scan history with trusted-device baselines and unknown-device alerts
  • +
  • Scan history with known-device baselines and explicit device approvals
  • Export follows the view: CSV, HTML, or the topology as PNG, JPG or HTML with SVG
  • KillerSpeed, device diagnostics and traceroute without leaving the app
  • Headless command line for scripts, scheduled tasks and RMM
  • @@ -229,9 +232,9 @@

    What it does

    Network topology

    F8 draws devices as movable boxes, grouped by role, type, address or vendor. The map is inferred from scan results, not switch cabling. Export the full arrangement as transparent PNG, themed JPG, or HTML containing scalable SVG.

    Keep Alive

    Watch up to sixteen devices at once, each as a card with a latency graph, packet loss, and its own event log of exactly when it dropped and came back. Every card carries its own checks: route, forward and reverse DNS, ping, ports and traceroute. Copy the lot straight into a ticket.

    Terminal built in

    A real terminal inside the app, running PowerShell 7 where it exists, with KillerShell's prompt and a KillerScan module that turns a scan into objects you can filter and pipe. Device actions open here: ping in color, SSH with the account you gave for that device.

    -

    History & trusted devices

    Completed GUI scans that find devices are saved and compared with the previous scan of the same target. The first nonempty scan creates a trusted baseline when no trusted devices exist; later scans flag unfamiliar devices.

    +

    History & trusted devices

    Saved Locations keep their own scan history. Comparisons use earlier scans of the same target on the same network. The first scan establishes which devices are known; later scans flag unfamiliar devices. Device approval is a separate choice.

    Services view

    F7 shows one row per open port rather than per device. Find which devices offer a service, sort or filter the list, and export it as CSV.

    -

    Profiles & deep scan

    Save targets as named profiles, then load or run them from the profiles sidebar. Optionally follow with Deep Scan, which checks ports 1-1024 plus additional service ports and collects more fingerprints. KillerSpeed is available on the rail.

    +

    Locations & deep scan

    Keep each customer site in its own Location, with separate saved scans, device names, approvals and history. Load saved targets from the Location picker, then press Scan. Deep Scan checks ports 1-1024 plus additional service ports and collects more fingerprints. KillerSpeed is available on the rail.

    Quick actions

    Right-click any device to copy its IP, MAC or hostname, or jump straight to it.
    Open its web UI, or launch RDP or SSH directly, and set a manual device type that sticks.

    Command line ready

    Scan several networks, deep-probe one host, inspect the active network, or resolve a MAC vendor offline. Filter and sort the results, select ports and device types, set limits and timeouts, show progress, and emit table, CSV, JSON or themed HTML output with dependable automation exit codes. See every command.

    Export

    Use Export for device CSV or interactive HTML, service CSV, topology PNG/JPG or HTML with SVG, Keep Alive CSV/HTML/PNG, and terminal text including scrollback.

    @@ -247,6 +250,7 @@

    What it does

- + + diff --git a/scan-landing/help.html b/scan-landing/help.html index b4c38889..5fbf414c 100644 --- a/scan-landing/help.html +++ b/scan-landing/help.html @@ -568,7 +568,7 @@

When the terminal has focus

diff --git a/scan-landing/index.html b/scan-landing/index.html index fa57ac0d..5d3d0e2c 100644 --- a/scan-landing/index.html +++ b/scan-landing/index.html @@ -193,11 +193,11 @@ Source on GitHub
-
version KillerScan v1.8.3
-
released 2026-10-19
-
size 4.40 MB exe
+
version KillerScan v1.9.0
+
released 2026-11-01
+
size 5.36 MB exe
platform Windows 10/11 x64
-
sha256 84920ca3573f45a85104e5e6165828ee
83f3c91510b69efb2bc5ea2c011209f8
+
sha256 03bc1239b777e97d76a1c884e2b2b4de
98d08eff3fbcde960064570a3d966355

Install or run portable. Free, open source, GPLv3.
No agent, account, subscription, or ads.
The app sends no usage analytics.
Network connections explained.

@@ -261,7 +261,7 @@

What it does

From bb1a38412a3a0cfc5edabff226a1abc9a1d8ac89 Mon Sep 17 00:00:00 2001 From: Steve the Killer Date: Sun, 1 Nov 2026 02:43:38 -0800 Subject: [PATCH 329/333] v1.9.0: repair package checksum decoding and runner HTTP fixture setup --- .github/workflows/chocolatey-release.yml | 1 + .github/workflows/nuget-engine-release.yml | 2 ++ .github/workflows/pr-build-test.yml | 3 +++ .github/workflows/winget-release.yml | 1 + tests/Release.Tests/PackageChecksum.Tests.ps1 | 22 +++++++++++++++++++ 5 files changed, 29 insertions(+) create mode 100644 tests/Release.Tests/PackageChecksum.Tests.ps1 diff --git a/.github/workflows/chocolatey-release.yml b/.github/workflows/chocolatey-release.yml index 6095df6a..07ed714d 100644 --- a/.github/workflows/chocolatey-release.yml +++ b/.github/workflows/chocolatey-release.yml @@ -49,6 +49,7 @@ jobs: # The package pins whatever was downloaded, so check it is the release release.ps1 built: # the published checksum and a valid signature from the release certificate. $sums = (Invoke-WebRequest -Uri "$base/SHA256SUMS.txt" -UseBasicParsing).Content + if ($sums -is [byte[]]) { $sums = [Text.Encoding]::UTF8.GetString($sums) } $listed = [regex]::Match($sums, '(?m)^KillerScan\.exe\s+([0-9A-Fa-f]{64})\s*$').Groups[1].Value if ($listed -ne $hash) { throw "KillerScan.exe does not match SHA256SUMS.txt (listed '$listed', downloaded $hash)." } $signature = Get-AuthenticodeSignature "$env:RUNNER_TEMP\KillerScan.exe" diff --git a/.github/workflows/nuget-engine-release.yml b/.github/workflows/nuget-engine-release.yml index 6ea0dc89..22a007c5 100644 --- a/.github/workflows/nuget-engine-release.yml +++ b/.github/workflows/nuget-engine-release.yml @@ -98,6 +98,8 @@ jobs: - name: Test run: | + # The hosted image includes IIS, which can reserve the loopback HTTP fixture's port. + Get-Service W3SVC, WAS -ErrorAction SilentlyContinue | Stop-Service -Force -ErrorAction Stop dotnet build tests/KillerScan.Engine.Tests/KillerScan.Engine.Tests.csproj -c Release --no-restore if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } foreach ($tfm in 'net48', 'net10.0') { diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index 0eaba759..fe05b8e3 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -70,6 +70,7 @@ jobs: FRAMEWORK: ${{ matrix.framework }} run: | if (!$env:WINDIR) { $env:WINDIR = $env:SYSTEMROOT } + Get-Service W3SVC, WAS -ErrorAction SilentlyContinue | Stop-Service -Force -ErrorAction Stop # No --worker or --internet: SpeedTest uses local fixtures only. dotnet run --project $env:PROJECT --framework $env:FRAMEWORK --configuration Release --no-build --no-restore 2>&1 | Tee-Object ci-results/console.log if ($LASTEXITCODE -ne 0) { throw 'Regression checks failed. See console.log.' } @@ -87,6 +88,8 @@ jobs: - name: Check the suite inventory and the PowerShell module contract if: matrix.name == 'application' run: | + ./tests/Release.Tests/PackageChecksum.Tests.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Package checksum response contract failed.' } ./tests/Run-FocusedSuites.ps1 -CheckOnly if ($LASTEXITCODE -ne 0) { throw 'tests/suites.json does not match the test runners.' } $exe = 'bin/Release/net48/KillerScan.exe' diff --git a/.github/workflows/winget-release.yml b/.github/workflows/winget-release.yml index d3f15f99..2f0dff90 100644 --- a/.github/workflows/winget-release.yml +++ b/.github/workflows/winget-release.yml @@ -123,6 +123,7 @@ jobs: Invoke-WebRequest -Uri $url -OutFile $exe $hash = (Get-FileHash $exe -Algorithm SHA256).Hash $sums = (Invoke-WebRequest -Uri "$base/SHA256SUMS.txt" -UseBasicParsing).Content + if ($sums -is [byte[]]) { $sums = [Text.Encoding]::UTF8.GetString($sums) } $listed = [regex]::Match($sums, '(?m)^KillerScan\.exe\s+([0-9A-Fa-f]{64})\s*$').Groups[1].Value if ($listed -ne $hash) { Write-Error "KillerScan.exe does not match SHA256SUMS.txt (listed '$listed', downloaded $hash)."; exit 1 } $signature = Get-AuthenticodeSignature $exe diff --git a/tests/Release.Tests/PackageChecksum.Tests.ps1 b/tests/Release.Tests/PackageChecksum.Tests.ps1 new file mode 100644 index 00000000..835a7529 --- /dev/null +++ b/tests/Release.Tests/PackageChecksum.Tests.ps1 @@ -0,0 +1,22 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$repo = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$expected = '03BC1239B777E97D76A1C884E2B2B4DE98D08EFF3FBCDE960064570A3D966355' +$base = 'https://example.invalid/release' +$body = "KillerScan.exe $expected`r`n" +foreach ($workflow in 'chocolatey-release.yml', 'winget-release.yml') { + $source = Get-Content -LiteralPath (Join-Path $repo ".github/workflows/$workflow") -Raw + $start = $source.IndexOf(' $sums = ') + $end = $source.IndexOf(' if ($listed -ne $hash)', $start) + if ($start -lt 0 -or $end -le $start) { throw "Checksum boundary missing: $workflow" } + $check = [scriptblock]::Create(($source.Substring($start, $end - $start) -replace '(?m)^ ', '')) + foreach ($content in @($body, [Text.Encoding]::UTF8.GetBytes($body))) { + function Invoke-WebRequest { [pscustomobject]@{ Content = $content } } + $listed = $null + . $check + if ($listed -ne $expected) { throw "$workflow failed for $($content.GetType().Name) response content." } + } + Write-Output "PASS: $workflow reads textual and binary checksum responses." +} From 76cff0e58ccde7da639154a54dd6b8b8dcb59fc0 Mon Sep 17 00:00:00 2001 From: Ales Vucina <21204857+alesvucina@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:59:13 +0100 Subject: [PATCH 330/333] Keep native classification authoritative when updating fork metadata --- Controls/ScanWorkspace/DeviceActions.cs | 4 ++-- Terminal/TerminalWidth.cs | 1 - Themes/App/98SE.xaml | 2 +- Themes/App/Defaults.xaml | 2 +- tests/SpeedTest.Tests/RecognitionUiTests.cs | 1 + 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Controls/ScanWorkspace/DeviceActions.cs b/Controls/ScanWorkspace/DeviceActions.cs index 0276e1d8..9473624d 100644 --- a/Controls/ScanWorkspace/DeviceActions.cs +++ b/Controls/ScanWorkspace/DeviceActions.cs @@ -90,7 +90,7 @@ private void SetType_Click(object sender, RoutedEventArgs e) // The automatic result stays on record next to the manual type. d.Recognition = NetworkScanner.AssessDevice(d, mac => _location.Overrides.Get(mac)); d.DeviceType = type; - if (d.Identification != null) DeviceIdentification.ApplyManualType(d, type); + if (d.Identification != null) d.Identification = d.Identification with { ManualType = type }; _filteredView?.Refresh(); if (_showTopology) RefreshTopology(); StatusText.Text = string.Format(Loc("Str_St_OverrideSet"), d.MacAddress, type); @@ -103,7 +103,7 @@ private void ClearOverride_Click(object sender, RoutedEventArgs e) PersistenceFeedback.Check(_location.Overrides.Set(d.MacAddress, null), _location.Overrides.Save, () => _location.Overrides.SaveError); d.Recognition = NetworkScanner.AssessDevice(d, mac => _location.Overrides.Get(mac)); d.DeviceType = d.Recognition.Type; - if (d.Identification != null) DeviceIdentification.ApplyManualType(d, null); + if (d.Identification != null) d.Identification = d.Identification with { ManualType = null }; _filteredView?.Refresh(); if (_showTopology) RefreshTopology(); StatusText.Text = string.Format(Loc("Str_St_OverrideCleared"), d.MacAddress); diff --git a/Terminal/TerminalWidth.cs b/Terminal/TerminalWidth.cs index 223c26de..170e7b9f 100644 --- a/Terminal/TerminalWidth.cs +++ b/Terminal/TerminalWidth.cs @@ -313,4 +313,3 @@ private static bool Contains(int[] ranges, int cp) ]; } } - diff --git a/Themes/App/98SE.xaml b/Themes/App/98SE.xaml index 46cf7fef..264edb96 100644 --- a/Themes/App/98SE.xaml +++ b/Themes/App/98SE.xaml @@ -4,7 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:po="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options" xmlns:sys="clr-namespace:System;assembly=mscorlib"> - 2,0,0,0 + 2,0,0,0 4,2,42,2 0,2,2,0 24 0,6,0,6 0,0,8,0 - 5,4,0,0 + 5,4,0,0 16,-4,0,0 0 diff --git a/tests/SpeedTest.Tests/RecognitionUiTests.cs b/tests/SpeedTest.Tests/RecognitionUiTests.cs index 60632128..94200237 100644 --- a/tests/SpeedTest.Tests/RecognitionUiTests.cs +++ b/tests/SpeedTest.Tests/RecognitionUiTests.cs @@ -35,6 +35,7 @@ internal static void Run() }; printer.Recognition = NetworkScanner.AssessDevice(printer, null); printer.DeviceType = printer.Recognition.Type; + NetworkScanner.IdentifyDevice(printer); // Exercise retained fork metadata through native overrides too. var old = new NetworkDevice { IpAddress = "192.0.2.11", DeviceType = "Windows" }; using var workspace = new ScanWorkspace("192.0.2.0/24", false); From 44a52bd33b4c6362824197907c2af1c0c312826d Mon Sep 17 00:00:00 2001 From: Ales Vucina <21204857+alesvucina@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:03:37 +0100 Subject: [PATCH 331/333] Set isolated CI data after the runner starts --- .github/workflows/pr-build-test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index 8374afaf..a48d9939 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -14,8 +14,6 @@ concurrency: jobs: build: runs-on: windows-2025 - env: - KILLERSCAN_DATA_ROOT: ${{ runner.temp }}/KillerScan-ci-data timeout-minutes: 30 defaults: run: @@ -52,6 +50,9 @@ jobs: - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' + - name: Isolate fork test data + run: | + "KILLERSCAN_DATA_ROOT=$env:RUNNER_TEMP\KillerScan-ci-data" >> $env:GITHUB_ENV - name: Restore and build env: PROJECT: ${{ matrix.project }} From 2fee93dec0b9d2a291974db704f64b3022cc2de2 Mon Sep 17 00:00:00 2001 From: Ales Vucina <21204857+alesvucina@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:10:53 +0100 Subject: [PATCH 332/333] Use child PowerShell exit codes for CI test script results --- .github/workflows/pr-build-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index a48d9939..82e693ba 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -86,14 +86,14 @@ jobs: FRAMEWORK: ${{ matrix.framework }} run: | if (!$env:WINDIR) { $env:WINDIR = $env:SYSTEMROOT } - ./tests/Run-FocusedSuites.ps1 -Suite $env:SUITE -Framework $env:FRAMEWORK -Configuration Release 2>&1 | Tee-Object ci-results/focused.log + pwsh -NoProfile -NonInteractive -File ./tests/Run-FocusedSuites.ps1 -Suite $env:SUITE -Framework $env:FRAMEWORK -Configuration Release 2>&1 | Tee-Object ci-results/focused.log if ($LASTEXITCODE -ne 0) { throw 'Focused suites failed. See focused.log.' } - name: Check the suite inventory and the PowerShell module contract if: matrix.name == 'application' run: | - ./tests/Release.Tests/PackageChecksum.Tests.ps1 + pwsh -NoProfile -NonInteractive -File ./tests/Release.Tests/PackageChecksum.Tests.ps1 if ($LASTEXITCODE -ne 0) { throw 'Package checksum response contract failed.' } - ./tests/Run-FocusedSuites.ps1 -CheckOnly + pwsh -NoProfile -NonInteractive -File ./tests/Run-FocusedSuites.ps1 -CheckOnly if ($LASTEXITCODE -ne 0) { throw 'tests/suites.json does not match the test runners.' } $exe = 'bin/Release/net48/KillerScan.exe' pwsh -NoProfile -File tests/Module/ModuleContract.ps1 -Exe $exe 2>&1 | Tee-Object ci-results/module-pwsh.log From 1e7999c07dffe36363a89852c61686550c15d66e Mon Sep 17 00:00:00 2001 From: Ales Vucina <21204857+alesvucina@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:23:35 +0100 Subject: [PATCH 333/333] Complete HA menu icons and keep desktop-token validation off hosted runners --- Controls/ScanWorkspace.xaml | 10 +++++++--- docs/fork-upstream-1.9.md | 4 ++++ tests/SpeedTest.Tests/UpdateHelperLaunchTests.cs | 5 ++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Controls/ScanWorkspace.xaml b/Controls/ScanWorkspace.xaml index 1ba7f17d..97403826 100644 --- a/Controls/ScanWorkspace.xaml +++ b/Controls/ScanWorkspace.xaml @@ -425,7 +425,9 @@ - + + + @@ -751,8 +753,10 @@ - - + + + + diff --git a/docs/fork-upstream-1.9.md b/docs/fork-upstream-1.9.md index 2f55af9d..7b7800db 100644 --- a/docs/fork-upstream-1.9.md +++ b/docs/fork-upstream-1.9.md @@ -75,6 +75,10 @@ or privately modified data folder can be upgraded. unchanged survey files. The first read preserves their original file bytes. - Workflow mappings are checked for duplicate keys; every package/monitor job has the fork guard. The test suite inventory includes the new compatibility tests. +- Hosted CI runs fixture-based update publication checks. The actual elevated + relaunch with a signed-in desktop token remains the separate SN02 desktop + check: hosted runners can expose Explorer without such a usable token, so that + single interactive case is explicitly skipped there and is not certified here. These checks use fabricated devices, scratch data and controlled listeners. No customer-network scan, production HA connection or installed update/uninstall diff --git a/tests/SpeedTest.Tests/UpdateHelperLaunchTests.cs b/tests/SpeedTest.Tests/UpdateHelperLaunchTests.cs index ce81af61..b64b7b05 100644 --- a/tests/SpeedTest.Tests/UpdateHelperLaunchTests.cs +++ b/tests/SpeedTest.Tests/UpdateHelperLaunchTests.cs @@ -82,7 +82,10 @@ internal static void Run() private static void ElevatedRelaunch(string root, byte[] standIn) { using var self = Process.GetCurrentProcess(); - if (!KillerFamily.Deployment.InstallerExitCodes.IsElevated() + // Hosted runners may expose Explorer without a usable signed-in desktop token. + // This remains an SN02 desktop check; the fixture-based publication cases above run in CI. + if (string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) + || !KillerFamily.Deployment.InstallerExitCodes.IsElevated() || !Process.GetProcessesByName("explorer").Any(shell => shell.SessionId == self.SessionId)) { Console.WriteLine("SKIP the real elevated relaunch needs an elevated process on the signed-in desktop.");