From 658fda094fef79572f8f6f1383a223d13237779f Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 29 Sep 2026 01:03:54 +0200 Subject: [PATCH] Keep what the window knows about files; one publisher memory per pass The owner's S-1 decision (ADR-13): a full reading after a plan, after a change to what is installed, and when a question needs a family the window has not read, keeps the signature, version and hash of every file the window already asked about. Only F5 and the first look verify everything again. Keyed by the file path, so a new service on an already verified host has its answer and an entry whose path changed is verified afresh. "Not read" is not kept. SecondPass.Keep carries the answers onto a fresh listing, and SecondPass.Fill asks only about files no entry has an answer for, which changes nothing for callers that hand it a fresh listing (the command line, F5, the details panel). The window distinguishes Relisting.Afresh from Relisting.Keeping and takes the answers from the rows before they are replaced, so the column does not flicker. MainViewModel.LoadKeepingAsync is what runs after a plan. Backlog 468: the publisher memory of the Windows inspector lived as long as the window, longer than any F5. IBinaryInspector.ForOnePass hands every pass a fresh inspector, so F5 really does read everything again. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + src/Bws.Core/IBinaryInspector.cs | 18 +++ src/Bws.Core/SecondPass.cs | 68 ++++++++- src/Bws.Core/WindowsBinaryInspector.cs | 13 ++ src/Bws.Gui/MainWindow.Carrying.cs | 5 +- src/Bws.Gui/ViewModels/MainViewModel.cs | 15 +- .../ViewModels/Readings.SecondPhase.cs | 7 +- src/Bws.Gui/ViewModels/Readings.cs | 33 +++- src/Bws.Gui/ViewModels/Relisting.cs | 26 ++++ tests/Bws.Core.Tests/SecondPassKeepTests.cs | 128 ++++++++++++++++ tests/Bws.Gui.Tests/KeptFileAnswersTests.cs | 144 ++++++++++++++++++ 11 files changed, 445 insertions(+), 17 deletions(-) create mode 100644 src/Bws.Gui/ViewModels/Relisting.cs create mode 100644 tests/Bws.Core.Tests/SecondPassKeepTests.cs create mode 100644 tests/Bws.Gui.Tests/KeptFileAnswersTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a87d8..6f0b0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ is not part of this repository. - Reading who depends on each entry - the Required by column, `required:` in a query and `bws list --required-by` - asks about several entries at once and takes a fraction of the time it did. +- After a plan is carried out, after something is installed or removed, and when a column or a + query needs something the window has not read yet, the window no longer verifies every + signature again. It keeps what it already knows about files whose path has not changed and + verifies only new or changed ones. F5 still verifies everything again, so a file replaced by + someone else in the meantime shows its new verdict after the next F5. ## [0.3.0] - 2026-09-25 diff --git a/src/Bws.Core/IBinaryInspector.cs b/src/Bws.Core/IBinaryInspector.cs index c9f7b6c..266a66a 100644 --- a/src/Bws.Core/IBinaryInspector.cs +++ b/src/Bws.Core/IBinaryInspector.cs @@ -43,4 +43,22 @@ public interface IBinaryInspector /// Absent when the path names nothing on disk, denied when it is there and unreadable. /// Reading ReadHash(string file); + + /// + /// The inspector ONE pass of the second phase asks through - backlog 468, 2026-09-29. + /// + /// Whatever an inspector remembers between files lives as long as one pass and no longer. + /// The Windows one keeps the publisher of every catalogue it has opened, and the window holds + /// one inspector for its whole life - so a catalogue replaced under the same name went on + /// showing its old publisher until the window closed, F5 or no F5. Since the same day F5 is + /// promised to verify everything again (`ADR-13`, the owner's S-1 decision), which a memory + /// older than the F5 would quietly break. + /// + /// This rather than the caller clearing the memory, because a pass is several threads + /// asking at once and the details panel can ask about one entry while a pass is out. A fresh + /// inspector per pass shares nothing with the one before it, so there is no moment at which a + /// clear could land in the middle of somebody else's question. An inspector that remembers + /// nothing answers with itself. + /// + IBinaryInspector ForOnePass() => this; } diff --git a/src/Bws.Core/SecondPass.cs b/src/Bws.Core/SecondPass.cs index 2d9d90d..29af7c2 100644 --- a/src/Bws.Core/SecondPass.cs +++ b/src/Bws.Core/SecondPass.cs @@ -85,8 +85,10 @@ public static IReadOnlyList Fill( ArgumentNullException.ThrowIfNull(inspector); ArgumentOutOfRangeException.ThrowIfLessThan(degreeOfParallelism, 1); + // THROUGH AN INSPECTOR THAT REMEMBERS NOTHING FROM THE PASS BEFORE, since 2026-09-29 - + // backlog 468. Why it is the pass that asks for one is written on IBinaryInspector.ForOnePass. var files = Distinct(entries); - var answers = Ask(files, inspector, degreeOfParallelism); + var answers = Ask(files, inspector.ForOnePass(), degreeOfParallelism); var filled = new List(entries.Count); foreach (var entry in entries) @@ -117,7 +119,12 @@ private static List Distinct(IReadOnlyList entries) foreach (var entry in entries) { - if (entry.BinaryFile.Outcome == ReadOutcome.Present && seen.Add(entry.BinaryFile.Value!)) + // An entry already holding an answer - kept from an earlier pass by Keep - is not asked + // about again. Every entry a first reading hands over holds none, so for everybody who + // does not call Keep this line changes nothing. + if (entry.BinaryFile.Outcome == ReadOutcome.Present + && !Answered(entry) + && seen.Add(entry.BinaryFile.Value!)) { files.Add(entry.BinaryFile.Value!); } @@ -165,6 +172,60 @@ private static Dictionary Ask( private readonly record struct Answer( Reading Signature, Reading Version, Reading Hash); + /// + /// A fresh listing, with the answers an earlier reading already had about the SAME FILES. + /// + /// The owner's decision S-1 of 2026-09-28, recorded in `ADR-13`, and it exists for the + /// window alone. Carrying out a plan writes no file and no path - only a start type and the + /// delayed flag, with the path handed over as "unchanged" - so verifying every signature again + /// afterwards cannot give a new answer about anything the plan did, and it cost 4.3-5.0 s of + /// clock on two processors (P3 of the performance analysis). So after a plan, after a change to + /// what is installed and when a question needs a family the window has not read, the window + /// keeps what it knows. F5 keeps nothing and verifies everything again. + /// + /// Keyed by the FILE, never by the entry: a new service pointing at a svchost that was + /// already verified has an answer, and an entry whose path changed has none - a different path + /// is a different key, so it is verified afresh. "Not read" is not an answer and is not + /// kept: a file on another machine that was skipped is asked about again. + /// + /// The price, accepted by the owner before the decision: a file replaced by somebody else + /// between F5 and a plan keeps its old verdict until the next F5. + /// + public static IReadOnlyList Keep(IReadOnlyList fresh, IEnumerable earlier) + { + ArgumentNullException.ThrowIfNull(fresh); + ArgumentNullException.ThrowIfNull(earlier); + + var known = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in earlier) + { + if (entry.BinaryFile.Outcome == ReadOutcome.Present && Answered(entry)) + { + known.TryAdd(entry.BinaryFile.Value!, new Answer(entry.Signature, entry.FileVersion, entry.BinaryHash)); + } + } + + if (known.Count == 0) + { + return fresh; + } + + var kept = new List(fresh.Count); + + foreach (var entry in fresh) + { + kept.Add(entry.BinaryFile.Outcome == ReadOutcome.Present && known.TryGetValue(entry.BinaryFile.Value!, out var answer) + ? entry with { Signature = answer.Signature, FileVersion = answer.Version, BinaryHash = answer.Hash } + : entry); + } + + return kept; + } + + /// Whether a pass - this one or an earlier one kept by - already answered for this entry. + private static bool Answered(ScmEntry entry) => entry.Signature.Outcome != ReadOutcome.NotRead; + private static ScmEntry Fill(ScmEntry entry, Dictionary answers) { switch (entry.BinaryFile.Outcome) @@ -192,6 +253,9 @@ private static ScmEntry Fill(ScmEntry entry, Dictionary answers) BinaryHash = Reading.Denied(entry.BinaryFile.ErrorCode, entry.BinaryFile.Reason!) }; + case ReadOutcome.Present when Answered(entry): + return entry; + case ReadOutcome.Present: var answer = answers[entry.BinaryFile.Value!]; diff --git a/src/Bws.Core/WindowsBinaryInspector.cs b/src/Bws.Core/WindowsBinaryInspector.cs index 0861529..7528401 100644 --- a/src/Bws.Core/WindowsBinaryInspector.cs +++ b/src/Bws.Core/WindowsBinaryInspector.cs @@ -69,9 +69,22 @@ public sealed partial class WindowsBinaryInspector(NetworkPaths networkPaths = N /// Concurrent because the interface will read this from background threads once there /// is an interface. Cheap insurance against a bug that would only ever appear under /// load, in a thread nobody is watching. + /// + /// ONE PASS LONG SINCE 2026-09-29, and until then as long as the window - backlog 468. + /// The window holds one inspector for its whole life, so a catalogue replaced under the same + /// name showed its old publisher until it closed. Every pass now asks through + /// , which starts this empty. Whether Windows ever replaces a catalogue + /// under the same name is NOT CHECKED - what made it matter is that F5 is promised to verify + /// everything again, and a memory older than the F5 would break that promise quietly. /// private readonly ConcurrentDictionary _publishers = new(StringComparer.OrdinalIgnoreCase); + /// + /// A fresh inspector with nothing remembered, for one pass of the second phase - see + /// . It costs one empty dictionary. + /// + public IBinaryInspector ForOnePass() => new WindowsBinaryInspector(networkPaths); + /// /// Whether this file is one nobody asked us to reach for. /// diff --git a/src/Bws.Gui/MainWindow.Carrying.cs b/src/Bws.Gui/MainWindow.Carrying.cs index 75cc75d..843971f 100644 --- a/src/Bws.Gui/MainWindow.Carrying.cs +++ b/src/Bws.Gui/MainWindow.Carrying.cs @@ -149,8 +149,9 @@ internal async Task CarryOut() _model.Planned.Finished(await running.ConfigureAwait(true)); // Whatever moved, moved. Asking now rather than waiting up to a second means the list - // agrees with the panel by the time somebody looks up from it. - await _model.LoadAsync().ConfigureAwait(true); + // agrees with the panel by the time somebody looks up from it. KEEPING what is known + // about files since 2026-09-29 - the plan wrote none, `ADR-13`. + await _model.LoadKeepingAsync().ConfigureAwait(true); return true; } diff --git a/src/Bws.Gui/ViewModels/MainViewModel.cs b/src/Bws.Gui/ViewModels/MainViewModel.cs index c2f5606..c142026 100644 --- a/src/Bws.Gui/ViewModels/MainViewModel.cs +++ b/src/Bws.Gui/ViewModels/MainViewModel.cs @@ -317,9 +317,20 @@ public bool Interacting /// Handed straight on, because the window binds to this class and the state machine behind it /// is not something a window should have to know the name of. /// - public async Task LoadAsync() + public Task LoadAsync() => ReadMachineAsync(Relisting.Afresh); + + /// + /// Reads the machine in full after a plan, keeping what the window already knows about files. + /// + /// The owner's S-1 decision, 2026-09-28, recorded in `ADR-13`. A plan writes no file and + /// no path, so verifying every signature again afterwards could not give a new answer and cost + /// seconds on a small machine. F5 stays and verifies everything. + /// + public Task LoadKeepingAsync() => ReadMachineAsync(Relisting.Keeping); + + private async Task ReadMachineAsync(Relisting how) { - await _readings.LoadAsync().ConfigureAwait(true); + await _readings.LoadAsync(how).ConfigureAwait(true); // THE MACHINE OVERVIEW IS COUNTED FROM THE LISTING, AND THE LISTING ARRIVES AFTER THE WINDOW // DOES. Without this line every number on that screen is zero and stays zero: it is worked diff --git a/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs b/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs index b45c499..06846c2 100644 --- a/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs +++ b/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs @@ -230,9 +230,10 @@ private IReadOnlyList Fill(IReadOnlyList entries, ExtraRead /// Answered by reading the machine again rather than by filling in what is held. The /// entries kept from the last full reading are older than the rows on screen - a tick has been /// writing statuses into them since - so absorbing them would roll those changes back. Reading - /// again costs about a tenth of a second on sixteen processors on top of a pass that costs - /// seconds, and it is the - /// difference between a fresh answer and a stale one. + /// again costs about a tenth of a second on sixteen processors, and it is the difference between + /// a fresh answer and a stale one. Since 2026-09-29 that reading keeps what the window knows + /// about files (, the owner's word), so turning a column on + /// no longer verifies every signature again - only F5 does. /// internal bool WantsMore() => Asked(); diff --git a/src/Bws.Gui/ViewModels/Readings.cs b/src/Bws.Gui/ViewModels/Readings.cs index 1fb0348..95303e4 100644 --- a/src/Bws.Gui/ViewModels/Readings.cs +++ b/src/Bws.Gui/ViewModels/Readings.cs @@ -180,7 +180,10 @@ internal Readings( /// comes back to the interface thread, which is precisely why the hole was invisible: it /// is a question of ordering rather than of two threads touching one field. /// - internal async Task LoadAsync() + /// + /// Afresh for the first look and F5, keeping file answers after a plan - . + /// + internal async Task LoadAsync(Relisting how) { if (_reading) { @@ -191,7 +194,7 @@ internal async Task LoadAsync() try { - await LoadEverything().ConfigureAwait(true); + await LoadEverything(how).ConfigureAwait(true); } finally { @@ -203,7 +206,7 @@ internal async Task LoadAsync() /// The reading itself, without the guard, because the tick already holds it when it finds /// out that it needs a full one. /// - private async Task LoadEverything() + private async Task LoadEverything(Relisting how) { Says.Status = Texts.Of("gui.status.reading"); @@ -238,9 +241,20 @@ private async Task LoadEverything() Says.Incomplete = false; _failed = false; + // KEPT RATHER THAN VERIFIED AGAIN, SINCE 2026-09-29 - the owner's S-1 decision in `ADR-13`. + // Taken from the rows BEFORE they are replaced, because the rows are where every answer this + // window has ever had lives, the details panel's single-entry ones included. The price the + // owner accepted: a file replaced by somebody else keeps its old verdict until the next F5. + if (how == Relisting.Keeping) + { + entries = SecondPass.Keep(entries, _index.Everything); + } + // The families belong to THESE entries, not to the window, so a fresh listing starts with - // none of them. Anything else would show a signature read against a file that has since - // been replaced, which for an audit tool is worse than showing nothing. + // none of them HELD. Anything else would show a signature read against a file that has + // since been replaced, which for an audit tool is worse than showing nothing. Answers kept + // just above are in the entries themselves - whether the family counts as held for the whole + // list is still decided by the pass below, which asks only about what nobody answered. _have = ExtraRead.None; _tried = ExtraRead.None; @@ -295,7 +309,9 @@ internal async Task RefreshAsync() // thing to leave standing than a status that is one second old. if (WantsMore()) { - await LoadEverything().ConfigureAwait(true); + // Keeping, on the owner's word of 2026-09-29: turning a column on is not F5, and the + // signatures already on screen were verified since the last one. + await LoadEverything(Relisting.Keeping).ConfigureAwait(true); return; } @@ -332,8 +348,9 @@ internal async Task RefreshAsync() // The unguarded one, because the guard above is already held. Calling the // public entry point here would find its own flag raised and quietly do // nothing, which is the sort of deadlock-by-politeness that looks like the - // machine simply never installing anything. - await LoadEverything().ConfigureAwait(true); + // machine simply never installing anything. Keeping file answers: what was + // installed or removed brings its own path, and that one is verified. + await LoadEverything(Relisting.Keeping).ConfigureAwait(true); break; case Freshening.Moved: diff --git a/src/Bws.Gui/ViewModels/Relisting.cs b/src/Bws.Gui/ViewModels/Relisting.cs new file mode 100644 index 0000000..e8a88d4 --- /dev/null +++ b/src/Bws.Gui/ViewModels/Relisting.cs @@ -0,0 +1,26 @@ +namespace Bws.Gui.ViewModels; + +/// +/// How a full reading of the machine treats what the window already knows about files. +/// +/// Two ways since 2026-09-29, the owner's S-1 decision recorded in `ADR-13`. Until then every +/// full reading verified every signature again - about 12 s of processor over 797 entries, 4.3-5.0 s +/// of clock on two processors - including the one after a plan, which writes no file and no path +/// and so cannot have changed a single answer. The words for the two are in docs/03. +/// +internal enum Relisting +{ + /// + /// Everything read and verified again: the first look and F5. The only way a verdict older than + /// the reading goes away, and the reason F5 exists. + /// + Afresh, + + /// + /// The machine read again, and the signature, version and hash of every file the window has + /// already asked about kept rather than verified again: after a plan, after a change to what is + /// installed, and when a question needs a family the window has not read. A file with a new or + /// changed path is still verified. See SecondPass.Keep. + /// + Keeping +} diff --git a/tests/Bws.Core.Tests/SecondPassKeepTests.cs b/tests/Bws.Core.Tests/SecondPassKeepTests.cs new file mode 100644 index 0000000..b3ff903 --- /dev/null +++ b/tests/Bws.Core.Tests/SecondPassKeepTests.cs @@ -0,0 +1,128 @@ +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// File answers kept from an earlier reading, and an inspector that remembers nothing between passes. +/// +/// Written 2026-09-29, W4 of the performance series. Two changes that belong together because +/// both are about how OLD an answer may be. carries answers forward +/// on purpose - the owner's S-1 decision in `ADR-13` - and +/// makes sure nothing else does: the publisher memory of the Windows inspector used to outlive every +/// F5 the window was ever given (backlog 468). +/// +public sealed class SecondPassKeepTests +{ + private const string Spooler = @"C:\Windows\System32\spoolsv.exe"; + private const string Host = @"C:\Windows\System32\svchost.exe"; + + [Fact] + public void A_file_already_answered_is_kept_and_not_asked_about_again() + { + var earlier = SecondPass.Fill(Listing(), new FakeBinaryInspector()); + var inspector = new FakeBinaryInspector(); + + var filled = SecondPass.Fill(SecondPass.Keep(Listing(), earlier), inspector); + + Assert.Empty(inspector.SignaturesAsked); + Assert.Equal(earlier.Select(entry => entry.Signature.Value), filled.Select(entry => entry.Signature.Value)); + Assert.Equal(earlier.Select(entry => entry.BinaryHash.Value), filled.Select(entry => entry.BinaryHash.Value)); + } + + [Fact] + public void A_new_path_and_a_changed_path_are_verified_and_nothing_else_is() + { + // Keyed by the file, so the new service on an already verified svchost has its answer, and + // the spooler pointing somewhere else is a different key and is asked about. + const string Moved = @"D:\Spool\spoolsv.exe"; + const string New = @"C:\Program Files\Vendor\agent.exe"; + + var earlier = SecondPass.Fill(Listing(), new FakeBinaryInspector()); + + var fresh = new[] + { + At("Spooler", Moved), + At("Dhcp", Host), + At("RpcSs", Host), + At("VendorAgent", New) + }; + + var inspector = new FakeBinaryInspector(); + var filled = SecondPass.Fill(SecondPass.Keep(fresh, earlier), inspector); + + Assert.Equal([New, Moved], inspector.SignaturesAsked.Order(StringComparer.Ordinal)); + Assert.All(filled, entry => Assert.Equal(ReadOutcome.Present, entry.Signature.Outcome)); + } + + [Fact] + public void A_file_nobody_read_is_asked_about_again() + { + // "Not read" is what a skipped file on another machine answers, and it is not an answer. + var skipping = new FakeBinaryInspector(new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [Spooler] = Reading.NotRead() + }); + + var earlier = SecondPass.Fill(Listing(), skipping); + var inspector = new FakeBinaryInspector(); + + SecondPass.Fill(SecondPass.Keep(Listing(), earlier), inspector); + + Assert.Equal([Spooler], inspector.SignaturesAsked); + } + + [Fact] + public void Every_pass_asks_through_an_inspector_that_remembers_nothing_from_the_last() + { + // The window's own inspector refuses to be asked anything, so the pass only works if it + // asks through the one ForOnePass hands it. + var windowLong = new OnlyThroughAPass(); + + var filled = SecondPass.Fill(Listing(), windowLong); + + Assert.Equal(1, windowLong.Passes); + Assert.All(filled, entry => Assert.Equal(ReadOutcome.Present, entry.Signature.Outcome)); + } + + [Fact] + public void The_windows_inspector_starts_every_pass_empty() + { + // The only thing a fresh instance can promise without a real catalogue behind it: it IS a + // fresh instance, so the publisher memory of the one before it does not come along. + var inspector = new WindowsBinaryInspector(); + + Assert.NotSame(inspector, inspector.ForOnePass()); + } + + private static ScmEntry[] Listing() => + [ + At("Spooler", Spooler), + At("Dhcp", Host), + At("RpcSs", Host) + ]; + + private static ScmEntry At(string name, string file) => + Entries.Any with { ServiceName = name, DisplayName = name, BinaryFile = Reading.Present(file) }; + + /// An inspector that answers nothing itself and counts how many passes asked it for one. + private sealed class OnlyThroughAPass : IBinaryInspector + { + internal int Passes { get; private set; } + + public Reading ReadSignature(string file) => + throw new InvalidOperationException("Asked the window's own inspector rather than one for this pass."); + + public Reading ReadFileVersion(string file) => + throw new InvalidOperationException("Asked the window's own inspector rather than one for this pass."); + + public Reading ReadHash(string file) => + throw new InvalidOperationException("Asked the window's own inspector rather than one for this pass."); + + public IBinaryInspector ForOnePass() + { + Passes++; + + return new FakeBinaryInspector(); + } + } +} diff --git a/tests/Bws.Gui.Tests/KeptFileAnswersTests.cs b/tests/Bws.Gui.Tests/KeptFileAnswersTests.cs new file mode 100644 index 0000000..532e24d --- /dev/null +++ b/tests/Bws.Gui.Tests/KeptFileAnswersTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Concurrent; +using Bws.Core; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// What a full reading keeps about files, and when it keeps nothing - the owner's S-1 decision. +/// +/// Written 2026-09-29, W4 of the performance series, backlog 466. Until then every full +/// reading verified every signature again, about 12 s of processor over 797 entries on the machine +/// it was measured on. The owner decided: after a plan, after a change to what is installed, and +/// when a question needs a family the window has not read, the window keeps what it knows about a +/// file whose path did not change - and F5 verifies everything again, always. `ADR-13` carries the +/// decision and its price. Each of the four ways a full reading starts has its test here. +/// +/// Counted rather than timed, for the reason gives: what the +/// decision is about is WHICH files are verified, and a count says that on any machine. +/// +public sealed class KeptFileAnswersTests +{ + private const string Spooler = @"C:\Windows\System32\spoolsv.exe"; + private const string TimeService = @"C:\Windows\System32\w32time.dll"; + + [Fact] + public async Task A_plan_keeps_what_the_window_knows_about_files() + { + var (model, inspector, _) = await Verified(); + + await model.LoadKeepingAsync(); + + // Not one file asked about again, and the answers are still on the rows - the question + // that needed them still has both entries in it. + Assert.Equal(2, inspector.Asked.Count); + Assert.Equal(2, model.Rows.Count); + } + + [Fact] + public async Task F5_verifies_every_file_again() + { + // The other half of the decision, and the one that keeps the price bounded: whatever went + // stale since the last F5 goes away at the next one. + var (model, inspector, _) = await Verified(); + + await model.LoadAsync(); + + Assert.Equal(4, inspector.Asked.Count); + Assert.Equal(2, inspector.Asked.Count(file => file == Spooler)); + } + + [Fact] + public async Task After_something_is_installed_only_its_file_is_verified() + { + var (model, inspector, machine) = await Verified(); + const string Installed = @"C:\Program Files\Vendor\agent.exe"; + + machine.Install(Rows.Entry("VendorAgent") with { BinaryFile = Reading.Present(Installed) }); + + await model.RefreshAsync(); + + Assert.Equal([Installed], inspector.Asked.Skip(2)); + Assert.Equal(3, model.Rows.Count); + } + + [Fact] + public async Task A_column_that_needs_another_family_does_not_verify_signatures_again() + { + var inspector = new CountingInspector(); + var memory = new CountingMemory(); + var model = new MainViewModel(Machine(), new SteppedClock(), inspector, memory); + var columns = new ColumnBar(); + + model.ColumnsNeed = () => columns.Needs; + + await model.LoadAsync(); + + model.QueryText = "signed:no"; + + await model.RefreshAsync(); + + columns.Choices.Single(choice => choice.Column.Id == "memory").IsShown = true; + + await model.RefreshAsync(); + + Assert.True(memory.Asked > 0, "Turning the memory column on did not read memory."); + Assert.Equal(2, inspector.Asked.Count); + } + + /// A window over two entries, with both files verified once by a question that needed them. + private static async Task<(MainViewModel Model, CountingInspector Inspector, LiveMachine Machine)> Verified() + { + var inspector = new CountingInspector(); + var machine = Machine(); + var model = new MainViewModel(machine, new SteppedClock(), inspector, new CountingMemory()); + + await model.LoadAsync(); + + model.QueryText = "signed:no"; + + await model.RefreshAsync(); + + Assert.Equal(2, inspector.Asked.Count); + + return (model, inspector, machine); + } + + private static LiveMachine Machine() => new( + Rows.Entry("Spooler") with { BinaryFile = Reading.Present(Spooler) }, + Rows.Entry("W32Time") with { BinaryFile = Reading.Present(TimeService) }); + + /// + /// Says every file is unsigned, so both entries answer signed:no, and remembers which files it + /// was asked about. Concurrent, because the pass asks several at once. + /// + private sealed class CountingInspector : IBinaryInspector + { + internal ConcurrentQueue Asked { get; } = []; + + public Reading ReadSignature(string file) + { + Asked.Enqueue(file); + + return Reading.Present(new BinarySignature(SignatureStatus.NotSigned, 0, "Someone")); + } + + public Reading ReadFileVersion(string file) => Reading.Present("1.0.0.0"); + + public Reading ReadHash(string file) => Reading.Present(new string('a', 64)); + } + + private sealed class CountingMemory : IProcessMemoryReader + { + private int _asked; + + internal int Asked => _asked; + + public Reading Read(int processId) + { + Interlocked.Increment(ref _asked); + + return Reading.Present(new ProcessMemory(1024, 2048, 1)); + } + } +}