From d39889dd1d96cc0f523de8340e7070a88b41c9da Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 14 Oct 2026 10:32:08 +0200 Subject: [PATCH 1/3] Take and compare snapshots in the window The window gains a "Snapshots..." button beside Export... with two items. "Take snapshot..." writes this machine to a file the person chooses, the same file bws snapshot create writes. "Compare with this machine..." reads a snapshot and shows what changed since it in place of the list: one row per field that differs, entries added and removed, and above the table everything that makes the comparison less than exact. "Back to the list" or Escape returns. Nothing here changes a service, and a file at the chosen path that is not a snapshot is refused and left as it was. To give the window the same rules the command line has, the snapshot's file rules (size ceiling, UTF-8 only, folder, only a snapshot is ever replaced) moved from Bws.Cli to Bws.Core as SnapshotOnDisk, which names the fault and leaves the sentence to each front end. The two passes a snapshot reads on top of a listing moved to SnapshotPasses, and the helper that writes control and text direction characters out as code points moved to the core so the window applies it to values read from a file somebody else wrote. The command line says exactly what it said before for every bad file. A comparison no longer lists, entry by entry, a field Windows refused to both sides. On a real machine some service descriptions point at a language file that is not there, and every comparison listed the same entries as not fully compared. Such a field is now one caveat line naming the entries, and in --json a new refusedByBoth list at the end of the document. Those entries are no longer in notFullyCompared. A field one side holds a value for and the other does not is still listed against its entry, and that sentence now says "holds no value for". The narrowest window is 1000 points, up from 900, so the tenth button fits in the bar of actions. semgrep moves from 1.178.0 to 1.180.0, the newest stable release on the package index today. Its licence expression is unchanged. The gate's numbers are measured again by this pull request's own run. Co-Authored-By: Claude Opus 5.5 --- .github/requirements-semgrep.txt | 19 +- CHANGELOG.md | 26 ++ README.md | 19 +- src/Bws.Cli/DiffJson.cs | 14 +- src/Bws.Cli/DiffText.cs | 21 +- src/Bws.Cli/Program.cs | 38 +- src/Bws.Cli/Resources/cli.en.json | 8 +- src/Bws.Cli/SnapshotFiles.cs | 337 +++--------------- src/Bws.Cli/Texts.cs | 1 + src/{Bws.Cli => Bws.Core}/Printable.cs | 15 +- src/Bws.Core/SnapshotPasses.cs | 39 ++ src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs | 52 +++ src/Bws.Core/Snapshots/SnapshotDiff.cs | 71 ++-- src/Bws.Core/Snapshots/SnapshotOnDisk.cs | 290 +++++++++++++++ src/Bws.Gui/ActionBar.xaml | 22 ++ src/Bws.Gui/ActionBar.xaml.cs | 48 +++ src/Bws.Gui/DiffTable.xaml | 88 +++++ src/Bws.Gui/DiffTable.xaml.cs | 12 + src/Bws.Gui/DiffView.xaml | 119 +++++++ src/Bws.Gui/DiffView.xaml.cs | 24 ++ src/Bws.Gui/MainWindow.Keyboard.cs | 7 +- src/Bws.Gui/MainWindow.Overview.cs | 15 +- src/Bws.Gui/MainWindow.Suggesting.cs | 2 +- src/Bws.Gui/MainWindow.xaml | 7 + src/Bws.Gui/MainWindow.xaml.cs | 9 + src/Bws.Gui/Resources/gui.en.json | 57 +++ src/Bws.Gui/SnapshotDoors.cs | 114 ++++++ src/Bws.Gui/Themes/Values.xaml | 11 +- .../ViewModels/Catalogue.Comparisons.cs | 97 +++++ src/Bws.Gui/ViewModels/Catalogue.Views.cs | 3 + src/Bws.Gui/ViewModels/DiffCaveats.cs | 58 +++ src/Bws.Gui/ViewModels/DiffLines.cs | 89 +++++ src/Bws.Gui/ViewModels/DiffSheet.cs | 78 ++++ .../ViewModels/MainViewModel.Snapshots.cs | 61 ++++ src/Bws.Gui/ViewModels/MainViewModel.cs | 3 +- src/Bws.Gui/ViewModels/SnapshotWords.cs | 40 +++ src/Bws.Gui/ViewModels/Snapshotting.cs | 228 ++++++++++++ .../ConcurrencyGuards.cs | 9 + .../SilentCatchGuards.cs | 5 +- tests/Bws.Cli.Tests/AsciiDocumentTests.cs | 2 +- tests/Bws.Cli.Tests/DiffOutputTests.cs | 58 ++- .../MachineDocumentValueGuards.cs | 2 +- tests/Bws.Cli.Tests/TerminalTextTests.cs | 2 +- tests/Bws.Core.Tests/DiffPropertyTests.cs | 45 ++- tests/Bws.Core.Tests/Machines.cs | 18 + tests/Bws.Core.Tests/SnapshotDiffTests.cs | 50 ++- tests/Bws.Gui.Tests/ActionBarGuards.cs | 12 +- tests/Bws.Gui.Tests/CountedWords.cs | 6 +- tests/Bws.Gui.Tests/DiffSheetTests.cs | 139 ++++++++ tests/Bws.Gui.Tests/SnapshotWindowGuards.cs | 116 ++++++ tests/Bws.Gui.Tests/SnapshottingTests.cs | 125 +++++++ .../SnapshotContractTests.cs | 16 +- 52 files changed, 2349 insertions(+), 398 deletions(-) rename src/{Bws.Cli => Bws.Core}/Printable.cs (79%) create mode 100644 src/Bws.Core/SnapshotPasses.cs create mode 100644 src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs create mode 100644 src/Bws.Core/Snapshots/SnapshotOnDisk.cs create mode 100644 src/Bws.Gui/DiffTable.xaml create mode 100644 src/Bws.Gui/DiffTable.xaml.cs create mode 100644 src/Bws.Gui/DiffView.xaml create mode 100644 src/Bws.Gui/DiffView.xaml.cs create mode 100644 src/Bws.Gui/SnapshotDoors.cs create mode 100644 src/Bws.Gui/ViewModels/Catalogue.Comparisons.cs create mode 100644 src/Bws.Gui/ViewModels/DiffCaveats.cs create mode 100644 src/Bws.Gui/ViewModels/DiffLines.cs create mode 100644 src/Bws.Gui/ViewModels/DiffSheet.cs create mode 100644 src/Bws.Gui/ViewModels/MainViewModel.Snapshots.cs create mode 100644 src/Bws.Gui/ViewModels/SnapshotWords.cs create mode 100644 src/Bws.Gui/ViewModels/Snapshotting.cs create mode 100644 tests/Bws.Gui.Tests/DiffSheetTests.cs create mode 100644 tests/Bws.Gui.Tests/SnapshotWindowGuards.cs create mode 100644 tests/Bws.Gui.Tests/SnapshottingTests.cs diff --git a/.github/requirements-semgrep.txt b/.github/requirements-semgrep.txt index 136b999..b377ed3 100644 --- a/.github/requirements-semgrep.txt +++ b/.github/requirements-semgrep.txt @@ -12,11 +12,15 @@ # release turning a green branch red on a commit that touched nothing teaches everybody to # ignore the check. # -# THIS EXACT VERSION IS THE ONE THE GATE'S NUMBERS WERE MEASURED WITH. On 2026-09-22, against -# commit 693bced exported to a clean directory, semgrep 1.177.0 with `--config p/default` ran -# 131 rules over 301 files and reported zero findings and zero errors at level error. Those -# numbers are quoted in .github/scripts/semgrep_gate.py, so moving this line means measuring -# again rather than assuming the report will look the same. +# THE GATE'S NUMBERS ARE MEASURED WITH THE VERSION BELOW, AND ON 2026-10-14 THEY ARE BEING MEASURED +# AGAIN. The version moved from 1.178.0 to 1.180.0, the newest stable release on the package index +# that day, with the pull request that brought snapshots to the window. The numbers that stood here +# were from 1.177.0: on 2026-09-22, against commit 693bced exported to a clean directory, semgrep +# 1.177.0 with `--config p/default` ran 131 rules over 301 files and reported zero findings and zero +# errors at level error. 1.178.0 was never measured. The Semgrep run of that pull request is the new +# measurement, and its numbers replace these here and in .github/scripts/semgrep_gate.py in a commit +# of their own - moving this line means measuring again rather than assuming the report will look +# the same. # # 301 OF 516 FILES, AND THAT IS NOT A FAULT. Semgrep's own default ignore patterns exclude # tests/, which is 212 of this repository's files. So a healthy run of this tree reads src/, @@ -24,11 +28,12 @@ # shape of a scan that ran somewhere else. The gate prints the number on every run for exactly # that reason. # -# Licence, read on 2026-09-22 from this exact version's own metadata rather than recalled. The +# Licence, read on 2026-09-22 from that version's own metadata rather than recalled, and read again +# on 2026-10-14 from 1.180.0's, which says the same. The # package index reports `license_expression` as LGPL-2.1-or-later, with the older `license` # field null and no licence classifier - which is the modern shape and worth saying, because a # reader checking the field that used to hold this would find nothing and could conclude the # licence was unstated. It analyses the source tree in CI and is never linked into anything this # project ships, so it creates no distribution obligation - see THIRD-PARTY-NOTICES.md for what # does. -semgrep==1.178.0 +semgrep==1.180.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1802a..41545bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,32 @@ is not part of this repository. ## [Unreleased] +### Added + +- Snapshots in the window. A "Snapshots..." button beside Export... offers "Take snapshot...", which + writes this machine to a file you choose - the same file `bws snapshot create` writes, readable by + either - and "Compare with this machine...", which reads a snapshot and shows what changed since it + in place of the list: one row per field that differs, the entries added and removed, and above + the table everything that makes the comparison less than exact, such as a snapshot taken on + another machine or without administrator rights. "Back to the list" or Escape returns. Nothing in + either changes a service, and a file at the chosen path that is not a snapshot is refused and left + as it was. A value read from a snapshot shows a control or text direction character as its code + point, as the command line does. + +### Changed + +- A comparison no longer lists, entry by entry, a field Windows refused to both sides. On a real + machine a few service descriptions point at a language file that is not there, and Windows refuses + them to every reader - so every comparison listed the same entries as "not fully compared" while + nothing about them had changed. Such a field is now one line among the caveats, naming the entries, + and a snapshot compared with a copy of itself says "No differences." again. In `--json` the field + and its entries are in a new `refusedByBoth` list at the end of the document, and those entries are + no longer in `notFullyCompared`. A field one side holds a value for and the other does not is still + listed against its entry, and the sentence there now says "holds no value for" rather than "never + read". +- The window cannot be made narrower than 1000 points, up from 900, so the new button fits in the + bar of actions. + ### Fixed - `bws kill`, and Force stop in the window, no longer announce that they end a service's process diff --git a/README.md b/README.md index a396313..6fe6c3e 100644 --- a/README.md +++ b/README.md @@ -344,8 +344,10 @@ A tool that quietly fails to cover something is worse than one that says what it **Not built yet - the second half of the audit story:** -- **Snapshots are on the command line only.** The window shows, searches and changes, and it does - not take or compare snapshots. Use `bws snapshot` beside it. +- **Comparing two snapshot files is on the command line only.** The window takes a snapshot and + compares one with this machine, and `bws snapshot diff EARLIER LATER` compares two files - from two + machines, or from two moments. The window does not take a note with a snapshot either - `--note` + does. - **No stock Windows baseline.** The opening screen has a place for *how many of these are not from a clean install* and says it is not counted yet rather than showing a number it cannot stand behind. @@ -355,7 +357,8 @@ A tool that quietly fails to cover something is worse than one that says what it by script from the commands the diff shows you. - **No creating, deleting or repointing a service.** The launch path is read and reported, and it is not edited. -- **No recovery actions.** Not read, not shown, not searchable. +- **No recovery actions in the list.** They are read before a process is ended, so a plan can warn + that Windows will start a service again, and they are not shown, searchable or editable. **By design:** @@ -563,8 +566,11 @@ by another program, which it then says, leaves alone and saves nothing over. A r every operation before offering it, and copies the name or everything about the entry, display name and description included. *Export...* writes the rows on screen, in the columns you have on and the order you sorted them into, to a CSV file separated the way your Windows region -settings separate lists, so a double click opens it in columns. Ctrl+C over the list copies -everything about the chosen entry. Escape backs out of the innermost thing first. *Donate*, at the +settings separate lists, so a double click opens it in columns. *Snapshots...* beside it takes a +snapshot of the machine to a file, the same file `bws snapshot create` writes, or compares one with +the machine as it is now and shows what changed in place of the list, one row per field. Ctrl+C +over the list copies everything about the chosen entry. Escape backs out of the innermost thing +first. *Donate*, at the right end of the row above the search box, opens the project's support page in your browser - and in a window running as administrator it asks the desktop to open it, so the browser does not get those rights. @@ -627,7 +633,8 @@ item are the plan. Until then a snapshot at deployment and a diff against the ma **Working end to end:** the window and the command line over the same engine, the query language, plans with preview, cascade, refusal and the way back, stop, start, restart, force stop, force -restart and start type, snapshots and diffs between files and against the live machine, signatures, +restart and start type, snapshots and diffs between files and against the live machine - in the +window too, against the live machine - signatures, privileges, security descriptors, triggers, memory, per-user folding, keyboard and screen reader in the window, CSV export, JSON and exit codes on the command line. diff --git a/src/Bws.Cli/DiffJson.cs b/src/Bws.Cli/DiffJson.cs index c0a696e..b93a563 100644 --- a/src/Bws.Cli/DiffJson.cs +++ b/src/Bws.Cli/DiffJson.cs @@ -56,7 +56,8 @@ [.. diff.Uncertain.Select(Presence)], [.. diff.Changed.Select(Changed)], [.. diff.NotFullyCompared.Select(Changed)], diff.NeitherRead, - new LeftOutDocument(diff.LeftOut.Earlier, diff.LeftOut.Later)), + new LeftOutDocument(diff.LeftOut.Earlier, diff.LeftOut.Later), + [.. diff.RefusedByBoth.Select(field => new RefusedDocument(field.Field, field.Entries))]), Options); private static PresenceDocument Presence(EntryPresence entry) => @@ -86,6 +87,12 @@ private static ChangedDocument Changed(ChangedEntry entry) => /// Per-user session copies neither side was compared on, counted per side. Added 2026-09-30 /// (D-1) - an addition, so nothing a script already reads changes. /// + /// + /// Fields Windows refused to both snapshots, each with the entries it refused it on. Added + /// 2026-10-14 (backlog 559, owner's decision) and LAST, so the document only grew at its end. + /// What moved, said for whoever reads : an entry whose + /// only uncompared field was refused to both sides is no longer there - it is named here instead. + /// private sealed record DiffDocument( bool Differs, CaveatsDocument Caveats, @@ -95,7 +102,10 @@ private sealed record DiffDocument( IReadOnlyList Changed, IReadOnlyList NotFullyCompared, IReadOnlyList NeitherRead, - LeftOutDocument InstancesLeftOut); + LeftOutDocument InstancesLeftOut, + IReadOnlyList RefusedByBoth); + + private sealed record RefusedDocument(string Field, IReadOnlyList Entries); /// /// Metadata one of the two files does not carry, as the field names the file uses - a diff --git a/src/Bws.Cli/DiffText.cs b/src/Bws.Cli/DiffText.cs index 2633c35..bf0e5a6 100644 --- a/src/Bws.Cli/DiffText.cs +++ b/src/Bws.Cli/DiffText.cs @@ -44,8 +44,8 @@ internal static string Render(SnapshotDiff diff) Changed(text, "cli.diff.changed", diff.Changed); // Also its own heading, for the same reason one level down. These entries have - // nothing different about them - one of the two snapshots simply never read a field, - // so nobody can say either way. Filed under "changed" they read as drift that is not + // nothing different about them - one of the two snapshots holds no value for a field, + // so nobody can say either way. A field refused to BOTH is not here but in the caveats. Filed under "changed" they read as drift that is not // there, which is how the first version of this printed "5 changed, 0 differences". Changed(text, "cli.diff.notFullyCompared", diff.NotFullyCompared); @@ -80,7 +80,11 @@ private static void Caveats(StringBuilder text, SnapshotDiff diff) (diff.LeftOut.Any, () => Texts.Of("cli.diff.caveat.instancesLeftOut", diff.LeftOut.Earlier, diff.LeftOut.Later)) ]; - var said = lines.Where(line => line.Says).Select(line => line.Line()).ToList(); + var said = lines + .Where(line => line.Says) + .Select(line => line.Line()) + .Concat(diff.RefusedByBoth.Select(Refused)) + .ToList(); foreach (var line in said) { @@ -93,6 +97,17 @@ private static void Caveats(StringBuilder text, SnapshotDiff diff) } } + /// + /// A field Windows refused to both snapshots, said once with the entries it was refused on - + /// backlog 559, owner's decision of 2026-10-14. Until then each of those entries stood under + /// "not fully compared", so this machine compared with itself a minute later printed ten of + /// them every time. + /// + private static string Refused(RefusedField field) => + field.Entries.Count == 1 + ? Texts.Of("cli.diff.caveat.refusedByBoth.one", field.Field, field.Entries.Count, string.Join(", ", field.Entries)) + : Texts.Of("cli.diff.caveat.refusedByBoth.many", field.Field, field.Entries.Count, string.Join(", ", field.Entries)); + private static void Presence(StringBuilder text, string heading, IReadOnlyList entries) { if (entries.Count == 0) diff --git a/src/Bws.Cli/Program.cs b/src/Bws.Cli/Program.cs index c2e1af1..91b481d 100644 --- a/src/Bws.Cli/Program.cs +++ b/src/Bws.Cli/Program.cs @@ -135,20 +135,12 @@ if (options.Live) { - // Signatures and hashes are read here for the same reason snapshot create reads - // them: the file on the other side has them. Comparing against a reading that - // skipped them would mark every entry as "one side never read this", which is - // 810 admissions and no answer. - var before2 = stopwatch.ElapsedMilliseconds; - entries = SecondPass.Fill(entries, new WindowsBinaryInspector(networkPaths)); - inspected = stopwatch.ElapsedMilliseconds - before2; - - // The same third family, and for the same sentence: the file on the other side has - // it. Left out, every entry lands in "neitherRead" - which is not a difference, so a - // comparison reports none while the field goes uncompared. - before2 = stopwatch.ElapsedMilliseconds; - entries = RequiredByPass.Fill(entries, catalog!); - asked = stopwatch.ElapsedMilliseconds - before2; + // Signatures, hashes and who depends on each entry are read here for the same reason + // snapshot create reads them: the file on the other side has them. Comparing against a + // reading that skipped them would mark every entry as "one side never read this", + // which is 810 admissions and no answer. One call since 2026-10-14, shared with + // snapshot create and the window - SnapshotPasses says why. + entries = SnapshotPasses.Fill(entries, catalog!, new WindowsBinaryInspector(networkPaths), out inspected, out asked); after = Snapshot.Of(entries, note: null, new SystemClock()); } @@ -224,20 +216,10 @@ return ExitCode.Usage; } - // Signatures and the hash are read every time here, unlike in a listing. A snapshot - // is taken deliberately and kept for months, so being comparable against the next - // one matters more than the several seconds it costs - and a snapshot missing them - // would compare against one that has them as though the machine had changed. - var before = stopwatch.ElapsedMilliseconds; - entries = SecondPass.Fill(entries, new WindowsBinaryInspector(networkPaths)); - inspected = stopwatch.ElapsedMilliseconds - before; - - // AND WHO DEPENDS ON EACH ENTRY, EVERY TIME, on the argument above and for a small fraction - // of the price - RequiredByPass carries the measurement. No switch here: one taken without it - // would compare against one that has it as though every service had lost its dependents. - before = stopwatch.ElapsedMilliseconds; - entries = RequiredByPass.Fill(entries, catalog!); - asked = stopwatch.ElapsedMilliseconds - before; + // Signatures, the hash and who depends on each entry are read every time here, unlike in a + // listing - SnapshotPasses carries the argument, and since 2026-10-14 the one place it is + // read for the command line and the window alike. + entries = SnapshotPasses.Fill(entries, catalog!, new WindowsBinaryInspector(networkPaths), out inspected, out asked); var snapshot = Snapshot.Of(entries, options.Note, new SystemClock()); var target = SnapshotFiles.Target(options.Path, snapshot.Metadata); diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 854559a..b3f8fa0 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -273,12 +273,12 @@ "cli.diff.entry": " {0} {1}", "cli.diff.field.configuration": " {0}: {1} -> {2}", "cli.diff.field.state": " {0}: {1} -> {2} (running state, not configuration)", - "cli.diff.field.incomparable": " {0}: not compared, one of the two snapshots never read it", + "cli.diff.field.incomparable": " {0}: not compared, one of the two snapshots holds no value for it", "cli.diff.nothing": "nothing", "cli.diff.summary": "Added {0}, removed {1}, changed {2}. Fields differing: {3} in configuration, {4} in running state.", "cli.diff.summaryIncomplete": "Not a complete comparison:", - "cli.diff.incomplete.neverRead.many": " {0} entries carry a field one snapshot never read.", - "cli.diff.incomplete.neverRead.one": " {0} entry carries a field one snapshot never read.", + "cli.diff.incomplete.neverRead.many": " {0} entries carry a field one snapshot holds no value for.", + "cli.diff.incomplete.neverRead.one": " {0} entry carries a field one snapshot holds no value for.", "cli.diff.incomplete.seenByOne.many": " {0} entries only one of the two could see.", "cli.diff.incomplete.seenByOne.one": " {0} entry only one of the two could see.", "cli.diff.caveat.elevation": "The two snapshots were taken at different privilege levels. The one without administrator rights holds fewer entries, so what is missing from it may never have been removed.", @@ -291,6 +291,8 @@ "cli.diff.caveat.notKnown": "At least one of the snapshots does not record these - it was written before they were, or the machine would not say - so they could not be checked: {0}", "cli.diff.caveat.instancesLeftOut": "Per-user session copies come and go with the people signed in, so they are not compared - earlier snapshot: {0}, later snapshot: {1}.", "cli.diff.caveat.neitherRead": "Neither snapshot holds these fields, so no entry was compared on them: {0}", + "cli.diff.caveat.refusedByBoth.many": "Windows would not give {0} to either snapshot for {1} entries, so they were not compared on it: {2}", + "cli.diff.caveat.refusedByBoth.one": "Windows would not give {0} to either snapshot for {1} entry, so it was not compared on it: {2}", "cli.info.timingCompared": "Read and compared two snapshots in {0} ms.", "cli.cell.megabytes": "{0} MB", diff --git a/src/Bws.Cli/SnapshotFiles.cs b/src/Bws.Cli/SnapshotFiles.cs index 21b8884..35e0bd9 100644 --- a/src/Bws.Cli/SnapshotFiles.cs +++ b/src/Bws.Cli/SnapshotFiles.cs @@ -1,308 +1,70 @@ using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Text; using Bws.Core.Snapshots; namespace Bws.Cli; /// -/// The snapshot as a file on somebody's disk: where it goes when they did not say, and what -/// comes back when they point at one. +/// The snapshot as a file on somebody's disk, in this front end's words and exit codes. /// /// Moved out of Program.cs on 2026-08-03 because the size ratchet said so, for the fifth -/// time. Telling a mistyped path apart from a disk that went away pushed that file fourteen -/// lines past a ceiling that may only ever go down, and the ceiling exists so that adding to the -/// longest file starts with looking for a seam. +/// time. The seam is between the run and the disk. What stays behind reads the command line, +/// chooses the verb and produces the document. What is here turns whatever is at the other end of a +/// path into a snapshot or into a sentence and a code. /// -/// The seam is between the run and the disk. What stays behind reads the command line, chooses -/// the verb and produces the document. What is here touches a path: it works one out when nobody -/// gave one, and it turns whatever is at the other end into a snapshot or into a sentence and a -/// code. Execution took the other half of the same file for the same reason, on the same -/// principle - the part that waits. +/// THE RULES THEMSELVES LEFT FOR THE CORE ON 2026-10-14, when the window began taking and +/// comparing snapshots (). The window references the core and nothing +/// else, and two copies of "only a snapshot is ever replaced" would answer the same file two ways the +/// first time one was changed. What stayed here is what only the command line has: the sentences on +/// standard error and the code a run ends with. Every sentence and every code is the one this file +/// produced before the move - the faults were drawn along the lines the old catches already drew. /// internal static class SnapshotFiles { - /// - /// The largest a file may be before this build refuses to treat it as a snapshot. - /// - /// Sixty four megabytes, against a measured 1.009 MB for this machine's 799 entries - so about - /// fifty thousand entries, which no Windows installation has. The number refuses files that are - /// not snapshots rather than snapshots that are large, and that is the whole of its job. - /// - private const long Biggest = 64L * 1024 * 1024; - - /// - /// Where the snapshot goes when nobody said. - /// - /// The machine and the moment, in the directory the person is standing in. E1's own example - /// writes a snapshot without naming a file, so a name has to be worked out - and it has to - /// be one somebody can recognise weeks later among a dozen others, which rules out anything - /// clever. Sorted by name is sorted by time, because the stamp runs from the largest unit - /// down. - /// - /// Not a directory of our own choosing. ADR-18 says the tool does not invent directories, - /// and a file appearing somewhere in a profile is a file nobody finds. - /// - /// The stamp is written with an invariant culture, and until 2026-08-03 it was written - /// with the machine's. An interpolated hole formats with CurrentCulture, and a - /// culture carries a calendar - so the same moment produced these names: - /// - /// - /// pl-PL, en-US, ja-JP bws-snapshot-MACHINE-20260803-194500.json - /// th-TH bws-snapshot-MACHINE-25690803-194500.json Buddhist year - /// ar-SA bws-snapshot-MACHINE-14480220-194500.json Hijri - /// fa-IR bws-snapshot-MACHINE-14050512-194500.json Persian - /// - /// - /// The timestamp INSIDE the file is unaffected - the serialiser writes ISO 8601 whatever the - /// machine thinks - so on those three systems the name of the file and the date in it said - /// different things, and the sentence above about sorting by name was false. The - /// quarantine name two files away had this right from the day it was written, which is what - /// makes this an omission rather than a decision. - /// + /// Where the snapshot goes when nobody said - see . internal static string Target(string given, SnapshotMetadata metadata) => - given.Length > 0 - ? given - : "bws-snapshot-" + metadata.Machine + "-" - + metadata.TakenAt.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + ".json"; + SnapshotOnDisk.Target(given, metadata); /// - /// Whether this file may be written over. - /// - /// Nothing may replace an existing file without being told to - that has held since - /// 2026-08-02 for a named file and, since 2026-08-03, for the name this tool works out itself. - /// - /// AND ONLY A SNAPSHOT IS EVER REPLACED, WITH --force OR WITHOUT IT, SINCE 2026-10-06 - - /// security report S-8, owner's decision, a change to the frozen meaning of the switch. Until - /// that day --force replaced any file at all and moved one it could not read into quarantine - /// first. The file was not lost, but it was gone from its place: a mistyped variable in a script - /// running as administrator or as SYSTEM took a configuration file away from the program that - /// reads it, and the run ended with code 0. Now everything that is not a snapshot this build reads - /// - another program's file, a snapshot from a schema this build does not know, one that is too - /// large or will not open - is refused in words and left exactly where it was. For evidence that - /// is better than quarantine ever was: the file is not even renamed. - /// - /// Asked before the signatures and again on the name that will be written, as it always - /// was. The second ask is also what catches a file that became something else in the second the - /// signatures took. - /// - /// A decision and nothing else - it moves nothing and writes nothing. That split is - /// what makes this the single place the rule lives, and the mutation runner is what asked - /// for it: the check was in two places, one early for the courtesy above and one late for - /// the worked-out name, and taking either one away changed no outcome because the other - /// still refused. Two gates for one rule means no single mutation can prove the rule, which - /// the runner reported as MISSED and was right to. + /// Whether this file may be written over, and the sentence when it may not - the rule is + /// . /// internal static bool MayWrite(string target, bool force, out string? refusal) { - refusal = null; - - // A FOLDER IS NEVER WRITTEN OVER, WITH OR WITHOUT --force, since 2026-09-30 - stability report - // D-6, measured: `bws snapshot create ` spent 1545 ms reading signatures and - // then ended with "Access to the path is denied." and code 2, because File.Exists says no for a - // folder and the refusal came only from the write. Asked here, the courtesy check before the - // expensive work says it in a second's less time and in words about what was typed. Writing - // INTO the folder under a worked-out name would be a new feature rather than this repair. - if (Directory.Exists(target)) - { - refusal = Texts.Of("cli.snapshot.isFolder", target); - - return false; - } - - if (!File.Exists(target)) - { - return true; - } - - if (WhyNotASnapshot(target) is { } why) - { - refusal = why + " " + Texts.Of("cli.snapshot.notReplaced"); - - return false; - } - - if (!force) - { - refusal = Texts.Of("cli.snapshot.fileExists", target); - - return false; - } - - return true; - } - - /// - /// Why what is already at this path is not a snapshot this build reads, or nothing when it is one. - /// - /// The same four sentences says about a file somebody points - /// snapshot diff at, because it is the same question about the same kind of file: too - /// large to be one, not UTF-8, could not be read, or read and not a snapshot - with the reason the - /// reader gave, which is what tells somebody holding a snapshot from a newer build why theirs is - /// refused. - /// - /// THE SIZE IS ASKED BEFORE A BYTE IS READ, and until 2026-10-06 it was not. The question - /// was asked by reading the whole file into one string - measured that day with the shipped 0.3.0: - /// snapshot create over a 1.5 GB file with --force ended after 4045 ms with code 1 and "Insufficient - /// memory to continue the execution of the program." had the ceiling since - /// backlog 308(c), and this read beside it did not. - /// - private static string? WhyNotASnapshot(string path) - { - try - { - if (new FileInfo(path).Length > Biggest) - { - return Texts.Of("cli.diff.tooBig", path, Biggest / (1024 * 1024)); - } - - return SnapshotJson.TryRead(ReadText(path), out _, out var failure) - ? null - : Texts.Of("cli.diff.notASnapshot", path, failure ?? string.Empty); - } - - // Ahead of the clause below for the reason Load gives: a decoder failure IS an ArgumentException. - catch (DecoderFallbackException) - { - return Texts.Of("cli.diff.notUtf8", path); - } - catch (Exception unreadable) when (unreadable is IOException or UnauthorizedAccessException or ArgumentException) + refusal = SnapshotOnDisk.WhyNotWritten(target, force) switch { - return Texts.Of("cli.diff.cannotRead", path, unreadable.Message); - } - } - - /// - /// The bytes at a path, as text, or nothing at all. - /// - /// A decoder that refuses, and until 2026-08-03 this was File.ReadAllText, which - /// substitutes. That call honours a byte order mark and otherwise assumes UTF-8, and when - /// the bytes are not UTF-8 it puts a replacement character in and carries on without a word. - /// - /// MEASURED on the same snapshot re-encoded four ways and compared against itself: - /// - /// - /// UTF-8 No differences. - /// UTF-8 with a mark No differences. - /// UTF-16 with a mark No differences. - /// Windows-1250 Changed (297) - /// - /// - /// Two hundred and ninety seven entries reported as changed, and not one of them had. - /// It takes one person opening a snapshot in an editor set to the machine's code page and - /// saving it - and with --exit-code that is a pipeline failing over drift that does not - /// exist. In a tool whose whole purpose is telling real drift from noise, silently guessing at - /// bytes is the one thing it may not do. - /// - /// The mark is still honoured, so a snapshot saved as UTF-16 by something else still reads. - /// Only bytes that are not any of those come back as a refusal. - /// - /// The bytes are not text this build can read. - private static string ReadText(string path) - { - using var stream = File.OpenRead(path); - using var reader = new StreamReader( - stream, - new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), - detectEncodingFromByteOrderMarks: true); + null => null, + { Fault: DiskFault.IsFolder } => Texts.Of("cli.snapshot.isFolder", target), + { Fault: DiskFault.AlreadyThere } => Texts.Of("cli.snapshot.fileExists", target), + var other => Said(other) + " " + Texts.Of("cli.snapshot.notReplaced") + }; - return reader.ReadToEnd(); + return refusal is null; } /// /// Reads one snapshot from disk, or says what is wrong with what was pointed at. - /// - /// Deliberately narrow catches rather than a broad one. The ways a path can be wrong are a - /// list somebody can finish - it is not there, it is a directory, it cannot be opened, it is - /// not spellable - which is exactly the argument the broad catches in this project are - /// allowed by, run backwards. /// /// - /// How the run should end if this returns false. Its own answer rather than a code the - /// caller assumes, because the two reasons a read fails are not the same person's fault. + /// How the run should end if this returns false. Its own answer rather than a code the caller + /// assumes, because the two reasons a read fails are not the same person's fault: a file that is + /// not there, a path that cannot be spelled, bytes that are not UTF-8 and a file that is not a + /// snapshot are what somebody pointed at (usage), and a disk that went away, a file another + /// process holds or a folder this account may not enter are the machine failing (runtime). /// internal static bool Load(string path, out Snapshot? snapshot, out int code) { - snapshot = null; code = ExitCode.Ok; - string content; - - try - { - // ASKED HOW BIG IT IS BEFORE READING IT ALL, SINCE 2026-09-03 - backlog 308(c). The - // reader below builds one string out of every byte, so a path pointing at something - // enormous ends the process with an out of memory failure and a stack trace, which is - // the one shape this tool's entry point cannot turn into a sentence worth reading. - // - // MEASURED RATHER THAN GUESSED: a snapshot of this machine, 799 entries, is 1.009 MB. - // Sixty four megabytes is therefore about fifty thousand entries, which no Windows - // installation has - so this refuses files that are not snapshots rather than - // snapshots that are large. - // - // A file that is not there falls through to the reader below, which produces the - // sentence about a path somebody got wrong. This clause only ever speaks about a file - // that exists. - if (File.Exists(path) && new FileInfo(path).Length > Biggest) - { - Console.Error.WriteLine(Texts.Of("cli.diff.tooBig", path, Biggest / (1024 * 1024))); - code = ExitCode.Usage; - - return false; - } - - content = ReadText(path); - } - - // AHEAD OF THE TWO BELOW, and it has to be: a decoder failure IS an ArgumentException, so - // the clause that calls a mistyped path a mistake would otherwise swallow it and blame - // the wrong thing. This is not a path somebody got wrong - it is a file that exists, - // opened cleanly, and holds bytes this build will not guess at. - catch (DecoderFallbackException) - { - Console.Error.WriteLine(Texts.Of("cli.diff.notUtf8", path)); - code = ExitCode.Usage; - - return false; - } - - // WHOSE FAULT IT WAS DECIDES THE CODE, and until 2026-08-03 every one of these was code 2 - - // the one this tool reserves for what somebody typed wrongly. A file that is not there and a - // path that cannot be spelled are indeed that. A disk that went away mid-read, a file another - // process is holding open and a directory this account may not enter are not: they are the - // machine failing, and calling them a typo teaches a monitor to ignore the code that says so. - // - // Step 5 of docs/12 asks exactly this question - can a script tell my fault from yours by - // this number - and here the answer was no. `audit.ps1` is green either way, because it asks - // whether a code is IN the table rather than whether it is the RIGHT one. - catch (Exception mistyped) when (mistyped is FileNotFoundException or DirectoryNotFoundException or ArgumentException) - { - Console.Error.WriteLine(Texts.Of("cli.diff.cannotRead", path, mistyped.Message)); - code = ExitCode.Usage; - - return false; - } - catch (Exception broke) when (broke is IOException or UnauthorizedAccessException) - { - Console.Error.WriteLine(Texts.Of("cli.diff.cannotRead", path, broke.Message)); - code = ExitCode.Runtime; - - return false; - } - - if (SnapshotJson.TryRead(content, out snapshot, out var failure)) + if (SnapshotOnDisk.TryLoad(path, out snapshot, out var refusal)) { return true; } // Names the file. Two are being read and a message about neither of them would leave - // somebody checking both. The reason is never null when the read failed, and saying so - // out loud is cheaper than a nullable sentence in a message. - Console.Error.WriteLine(Texts.Of("cli.diff.notASnapshot", path, failure ?? string.Empty)); + // somebody checking both. + Console.Error.WriteLine(Said(refusal)); - // A file that is not a snapshot is what somebody pointed at, so it keeps the usage code. - // The machine did its part here - it handed over every byte that was asked for. - code = ExitCode.Usage; + code = refusal.Fault == DiskFault.Unreadable ? ExitCode.Runtime : ExitCode.Usage; return false; } @@ -312,23 +74,18 @@ internal static bool Load(string path, out Snapshot? snapshot, out int code) /// /// Here rather than beside the verb it serves, and the reason is the size ratchet. /// Program.cs stood at 549 lines against a ceiling of 552 that may only ever go down, so the - /// branch this needs would have reddened the build - which is the ratchet working, not - /// getting in the way. This file already owns "turn a snapshot the person pointed at into - /// either a document or a sentence and a code", and a comparison refusing one of its two - /// sides is the same sentence arriving one step later. - /// - /// Unreachable through the command line today, and that is written down rather than left - /// to be discovered. Both sides of a two-file comparison have been through - /// SnapshotJson.TryRead, which refuses these documents already, and --live builds its - /// side from what the manager handed over, which cannot hold one service twice. What this - /// guards is the next caller - a window showing a comparison - and the cost of guarding it - /// now is one branch. + /// branch this needs would have reddened the build. This file already owns "turn a snapshot the + /// person pointed at into either a document or a sentence and a code", and a comparison refusing + /// one of its two sides is the same sentence arriving one step later. + /// + /// Unreachable through the command line today, and that is written down rather than left to be + /// discovered. Both sides of a two-file comparison have been through + /// SnapshotJson.TryRead, which refuses these documents already, and --live builds its side + /// from what the manager handed over, which cannot hold one service twice. /// /// - /// Annotated rather than left for the caller to assert with an exclamation mark. The compiler - /// then knows what the return value already meant, and the alternative - telling it to be - /// quiet twice at the call site - says the same thing while switching off the check that - /// would notice if it stopped being true. + /// Annotated rather than left for the caller to assert with an exclamation mark. The compiler then + /// knows what the return value already meant. /// internal static bool Compare( Snapshot before, Snapshot after, [NotNullWhen(true)] out SnapshotDiff? difference, out int code) @@ -348,4 +105,18 @@ internal static bool Compare( return false; } + + /// + /// The sentence for a file that could not be read as a snapshot - the four the command line has + /// said since 2026-08-03, chosen by the fault rather than by the exception that used to carry it. + /// A file that is not there and one the machine would not hand over share a sentence, and differ + /// only in the code ends with. + /// + private static string Said(DiskRefusal refusal) => refusal.Fault switch + { + DiskFault.TooBig => Texts.Of("cli.diff.tooBig", refusal.Path, SnapshotOnDisk.Biggest / (1024 * 1024)), + DiskFault.NotUtf8 => Texts.Of("cli.diff.notUtf8", refusal.Path), + DiskFault.NotASnapshot => Texts.Of("cli.diff.notASnapshot", refusal.Path, refusal.Detail), + _ => Texts.Of("cli.diff.cannotRead", refusal.Path, refusal.Detail) + }; } diff --git a/src/Bws.Cli/Texts.cs b/src/Bws.Cli/Texts.cs index 0c93daf..0848db9 100644 --- a/src/Bws.Cli/Texts.cs +++ b/src/Bws.Cli/Texts.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Reflection; using System.Text.Json; +using Bws.Core; namespace Bws.Cli; diff --git a/src/Bws.Cli/Printable.cs b/src/Bws.Core/Printable.cs similarity index 79% rename from src/Bws.Cli/Printable.cs rename to src/Bws.Core/Printable.cs index 03415aa..74d17a5 100644 --- a/src/Bws.Cli/Printable.cs +++ b/src/Bws.Core/Printable.cs @@ -2,10 +2,17 @@ using System.Globalization; using System.Text; -namespace Bws.Cli; +namespace Bws.Core; /// -/// A value from the machine or from a file, made safe to put in front of a terminal. +/// A value from the machine or from a file, made safe to put in front of a terminal - and, since +/// 2026-10-14, in front of a person reading somebody else's snapshot in the window. +/// +/// In the core since that day, moved from the command line when the window began comparing +/// snapshots (`docs/PROJEKT-DRUGA-POLOWA-AUDYTU-20261014.md`, W1). A window is not a terminal - no +/// character here can move a cursor in it - but the half of this set that reorders text works the +/// same in a cell as in a console: a file name in a crafted snapshot can show one extension and carry +/// another. One rule for both front ends rather than a second list in the window that would drift. /// /// Stability report C-4, owner's decision 2026-10-05. Names, paths, descriptions and the /// values inside a snapshot reached the terminal exactly as they were. A tab or a line break in @@ -24,12 +31,12 @@ namespace Bws.Cli; /// Text only. A JSON document escapes all of these anyway, through AsciiJson. And it is /// applied to VALUES, never to sentences: five templates of this tool break lines on purpose. /// -internal static class Printable +public static class Printable { private static readonly SearchValues Unprintable = SearchValues.Create(Characters()); /// The value with every character from the set above written out as its code point. - internal static string Of(string value) + public static string Of(string value) { ArgumentNullException.ThrowIfNull(value); diff --git a/src/Bws.Core/SnapshotPasses.cs b/src/Bws.Core/SnapshotPasses.cs new file mode 100644 index 0000000..5fa9503 --- /dev/null +++ b/src/Bws.Core/SnapshotPasses.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; + +namespace Bws.Core; + +/// +/// What a snapshot needs read on top of a listing: who signed each binary and who depends on each +/// entry, each timed on its own. +/// +/// One place since 2026-10-14, and the window is why. The command line read the two passes in +/// two places - taking a snapshot and comparing one against the machine - and the window taking and +/// comparing snapshots would have been the third copy of the same four lines. The third time a shape +/// appears it becomes a name. A copy that forgot one pass would take snapshots that compare against +/// the command line's as though every entry had lost its signature or its dependents, and nothing +/// would say so. +/// +/// Both passes, always, and no switch. A snapshot is taken deliberately and kept for months, +/// so being comparable against the next one matters more than the second it costs (about 1.1-1.2 s +/// for the whole snapshot over 801 entries on sixteen processors, measured 2026-10-14) - and one +/// missing either family would compare against one that has it as though the machine had changed. +/// +public static class SnapshotPasses +{ + /// The listing with signatures and dependents filled in, new records as every pass returns. + /// Milliseconds spent on the files, for the command line's --timing. + /// Milliseconds spent asking who depends on each entry. + public static IReadOnlyList Fill( + IReadOnlyList listed, IScmCatalog catalog, IBinaryInspector inspector, out long inspected, out long asked) + { + var started = Stopwatch.GetTimestamp(); + var signed = SecondPass.Fill(listed, inspector); + inspected = (long)Stopwatch.GetElapsedTime(started).TotalMilliseconds; + + started = Stopwatch.GetTimestamp(); + var answered = RequiredByPass.Fill(signed, catalog); + asked = (long)Stopwatch.GetElapsedTime(started).TotalMilliseconds; + + return answered; + } +} diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs b/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs new file mode 100644 index 0000000..23a31c1 --- /dev/null +++ b/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs @@ -0,0 +1,52 @@ +namespace Bws.Core.Snapshots; + +/// +/// What a comparison could not look at: fields one side refused, fields nobody asked about, and since +/// 2026-10-14 fields refused to both sides - the three readings tells apart +/// before it compares anything. +/// +/// Its own file since that day, when the third kind arrived and the comparison stood over the +/// share of its file ceiling the size guard counts as near. One subject, the unread, so one place. +/// +public sealed partial record SnapshotDiff +{ + /// Fields this machine refused. A fact about permissions on this entry. + private static IEnumerable Refused(EntryDocument entry) => + (entry.Unreadable?.Keys ?? Enumerable.Empty()).Select(Camel); + + /// Fields nobody asked about on that run. A fact about the run, not the entry. + private static IEnumerable NotAsked(EntryDocument entry) => + (entry.NotRead ?? []).Select(Camel); + + /// + /// The field names inside "unreadable" and "notRead" are written the same way as the + /// fields they talk about. They come from the property names, so they arrive capitalised + /// and the keys beside them do not. + /// + private static string Camel(string name) => + name.Length == 0 ? name : char.ToLowerInvariant(name[0]) + name[1..]; + + /// + /// What a comparison could not look at and says once rather than against each entry - fields + /// neither snapshot asked about, and fields Windows refused to both. One value handed down the + /// walk instead of two collections, so the per-entry comparison keeps the four parameters it had. + /// + private sealed class Unseen + { + internal HashSet NeitherRead { get; } = new(StringComparer.Ordinal); + + /// Field to entries, sorted by field so the document comes out the same every time. + internal SortedDictionary> RefusedByBoth { get; } = new(StringComparer.Ordinal); + + internal void Refused(string field, string serviceName) + { + if (!RefusedByBoth.TryGetValue(field, out var entries)) + { + entries = []; + RefusedByBoth.Add(field, entries); + } + + entries.Add(serviceName); + } + } +} diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.cs b/src/Bws.Core/Snapshots/SnapshotDiff.cs index 1c2e4b7..016b6c6 100644 --- a/src/Bws.Core/Snapshots/SnapshotDiff.cs +++ b/src/Bws.Core/Snapshots/SnapshotDiff.cs @@ -28,9 +28,11 @@ public sealed record FieldDifference(string Field, DifferenceGroup Group, string public sealed record EntryPresence(string ServiceName, string DisplayName); /// -/// Fields that could not be compared because at least one side never read them. Named rather -/// than dropped: silence about a field nobody could read is exactly what rule 8 forbids, and -/// "we did not compare this" is a different sentence from "this did not change". +/// Fields that could not be compared because one side holds no value for them - refused there, or +/// never asked. Named rather than dropped: silence about a field nobody could read is exactly what +/// rule 8 forbids, and "we did not compare this" is a different sentence from "this did not +/// change". A field Windows refused to BOTH sides is not here since 2026-10-14 - it is in +/// , said once for the comparison. /// public sealed record ChangedEntry( string ServiceName, @@ -38,6 +40,19 @@ public sealed record ChangedEntry( IReadOnlyList Differences, IReadOnlyList Incomparable); +/// +/// A field Windows refused to BOTH snapshots, and the entries it refused it on. +/// +/// Its own shape since 2026-10-14 (backlog 559, owner's decision). Until then a field refused +/// on both sides was named against each entry like a field only one side could read, so a machine +/// compared with itself a minute later listed ten entries as "not fully compared" every time - their +/// descriptions point at a resource file that is not there, and Windows says so to both snapshots. +/// Nothing about those entries is uncertain in a way one of the two snapshots could have settled, so +/// it is said once for the whole comparison, the way a field neither snapshot asked about is. +/// +/// Service names, in the order the comparison walked them. +public sealed record RefusedField(string Field, IReadOnlyList Entries); + /// /// What changed between two snapshots. /// @@ -84,16 +99,22 @@ public sealed record ChangedEntry( /// would be the same admission eight hundred times, the shape this project already met when a /// listing threatened to answer "I do not know" about every entry it had. /// +/// +/// Fields Windows refused to both snapshots, each with the entries it refused it on. Never part +/// of and never counted by - see +/// for why a refusal on both sides is said once rather than per entry. +/// /// /// The per-user session copies neither side was compared on - see . /// Matched by the role the type bits give an entry, never by the shape of its name. /// -public sealed record SnapshotDiff( +public sealed partial record SnapshotDiff( IReadOnlyList Added, IReadOnlyList Removed, IReadOnlyList Changed, IReadOnlyList NotFullyCompared, IReadOnlyList NeitherRead, + IReadOnlyList RefusedByBoth, IReadOnlyList Uncertain, ComparisonCaveats Caveats, InstancesLeftOut LeftOut) @@ -238,13 +259,13 @@ private static SnapshotDiff Between(Snapshot before, Snapshot after) var uncertain = new List(); var changed = new List(); var partial = new List(); - var neitherRead = new HashSet(StringComparer.Ordinal); + var unseen = new Unseen(); foreach (var entry in Ordered(later)) { if (left.TryGetValue(entry.ServiceName, out var was)) { - var difference = Compare(was, entry, neitherRead, ignored); + var difference = Compare(was, entry, unseen, ignored); if (difference is not null) { @@ -281,7 +302,8 @@ private static SnapshotDiff Between(Snapshot before, Snapshot after) removed, changed, partial, - [.. neitherRead.OrderBy(field => field, StringComparer.Ordinal)], + [.. unseen.NeitherRead.OrderBy(field => field, StringComparer.Ordinal)], + [.. unseen.RefusedByBoth.Select(pair => new RefusedField(pair.Key, pair.Value))], [.. uncertain.OrderBy(entry => entry.ServiceName, StringComparer.Ordinal)], caveats, new InstancesLeftOut(leftOutEarlier, leftOutLater)); @@ -324,7 +346,7 @@ private static List Kept(IReadOnlyList entries, ou /// says why - never listed as incomparable here, which would repeat that sentence on every row. /// private static ChangedEntry? Compare( - EntryDocument before, EntryDocument after, HashSet neitherRead, string[] ignored) + EntryDocument before, EntryDocument after, Unseen unseen, string[] ignored) { var was = SnapshotJson.Document(before); var now = SnapshotJson.Document(after); @@ -341,7 +363,14 @@ private static List Kept(IReadOnlyList entries, ou // field this build does not read at all. Repeating it per entry would put the same // sentence on every row - the "810 shrugs" this project already walked into once with // triggers. Said once, for the whole comparison. + // + // Refused on BOTH sides is the third case and it is about the entry again, but not about the + // comparison being unable to settle it: neither snapshot holds a value, so no second look + // could have told a change from none. Said once for the whole comparison since 2026-10-14 + // (backlog 559) - named per entry, it put the same ten entries under "not fully compared" + // every time this machine was compared with itself. var refused = Refused(before).Union(Refused(after), StringComparer.Ordinal); + var refusedByBoth = Refused(before).Intersect(Refused(after), StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal); var neither = NotAsked(before).Intersect(NotAsked(after), StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal); var skip = NotAsked(before) @@ -350,7 +379,7 @@ private static List Kept(IReadOnlyList entries, ou .Union(refused, StringComparer.Ordinal) .ToHashSet(StringComparer.Ordinal); - neitherRead.UnionWith(neither); + unseen.NeitherRead.UnionWith(neither); var differences = new List(); var incomparable = new List(); @@ -360,6 +389,14 @@ private static List Kept(IReadOnlyList entries, ou .Where(field => Comparable(field) && !ignored.Contains(field, StringComparer.Ordinal)) .OrderBy(field => field, StringComparer.Ordinal)) { + // Refused to both is a part of skip, so it is asked first - and flat rather than nested, + // because the shape guard counts a third level here as a method climbing towards its ceiling. + if (refusedByBoth.Contains(field)) + { + unseen.Refused(field, after.ServiceName); + continue; + } + if (skip.Contains(field)) { incomparable.Add(field); @@ -384,22 +421,6 @@ private static List Kept(IReadOnlyList entries, ou : new ChangedEntry(after.ServiceName, after.DisplayName, differences, incomparable); } - /// Fields this machine refused. A fact about permissions on this entry. - private static IEnumerable Refused(EntryDocument entry) => - (entry.Unreadable?.Keys ?? Enumerable.Empty()).Select(Camel); - - /// Fields nobody asked about on that run. A fact about the run, not the entry. - private static IEnumerable NotAsked(EntryDocument entry) => - (entry.NotRead ?? []).Select(Camel); - - /// - /// The field names inside "unreadable" and "notRead" are written the same way as the - /// fields they talk about. They come from the property names, so they arrive capitalised - /// and the keys beside them do not. - /// - private static string Camel(string name) => - name.Length == 0 ? name : char.ToLowerInvariant(name[0]) + name[1..]; - private static bool Comparable(string field) => field != Identity && field != Unstable && !Bookkeeping.Contains(field, StringComparer.Ordinal); diff --git a/src/Bws.Core/Snapshots/SnapshotOnDisk.cs b/src/Bws.Core/Snapshots/SnapshotOnDisk.cs new file mode 100644 index 0000000..6ccd6e0 --- /dev/null +++ b/src/Bws.Core/Snapshots/SnapshotOnDisk.cs @@ -0,0 +1,290 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +namespace Bws.Core.Snapshots; + +/// What is wrong with a path somebody pointed at as a snapshot, or asked one to be written to. +public enum DiskFault +{ + /// Not there, or a path that cannot be spelled - what somebody typed. + Mistyped, + + /// There, and the machine would not hand it over - a disk that went away, a lock, a permission. + Unreadable, + + /// Larger than , so not something this build wrote. + TooBig, + + /// Bytes that are not UTF-8, which this build refuses rather than guesses at. + NotUtf8, + + /// Read cleanly and not a snapshot this build reads - the reader says why. + NotASnapshot, + + /// A folder stands where a snapshot was to be written. + IsFolder, + + /// A snapshot stands there, and nobody said it may be replaced. + AlreadyThere +} + +/// +/// What was said underneath: Windows' own sentence for a read it refused, the reader's reason for a +/// file that is not a snapshot. Empty where the fault says everything there is to say. +/// +public sealed record DiskRefusal(DiskFault Fault, string Path, string Detail); + +/// +/// The snapshot as a file on somebody's disk: what it is called when nobody named it, whether a path +/// may be written to, and what comes back when somebody points at one. +/// +/// Moved here from the command line on 2026-10-14, when the window began taking and comparing +/// snapshots (`docs/PROJEKT-DRUGA-POLOWA-AUDYTU-20261014.md`, W1). The window references the core +/// and nothing else, so the alternative was a second copy of every rule below - the size ceiling, +/// the refusal to guess at bytes, the folder, and S-8's "only a snapshot is ever replaced" - and two +/// copies of a rule about somebody's evidence answer the same file two ways the first time one of +/// them is changed. What did not move is the words: this says WHICH fault, and each front end says it +/// in its own sentences. +/// +public static class SnapshotOnDisk +{ + /// + /// The largest a file may be before this build refuses to treat it as a snapshot. + /// + /// Sixty four megabytes, against a measured 1.009 MB for this machine's 799 entries - so about + /// fifty thousand entries, which no Windows installation has. The number refuses files that are + /// not snapshots rather than snapshots that are large, and that is the whole of its job. + /// + public const long Biggest = 64L * 1024 * 1024; + + /// + /// Where the snapshot goes when nobody said: the name given, or the machine and the moment. + /// + /// E1's own example writes a snapshot without naming a file, so a name has to be worked out - and + /// it has to be one somebody can recognise weeks later among a dozen others, which rules out + /// anything clever. Sorted by name is sorted by time, because the stamp runs from the largest unit + /// down. + /// + /// The stamp is written with an invariant culture, and until 2026-08-03 it was written with the + /// machine's. An interpolated hole formats with CurrentCulture, and a culture carries a + /// calendar - so the same moment produced these names: + /// + /// + /// pl-PL, en-US, ja-JP bws-snapshot-MACHINE-20260803-194500.json + /// th-TH bws-snapshot-MACHINE-25690803-194500.json Buddhist year + /// ar-SA bws-snapshot-MACHINE-14480220-194500.json Hijri + /// fa-IR bws-snapshot-MACHINE-14050512-194500.json Persian + /// + /// + /// The timestamp INSIDE the file is unaffected - the serialiser writes ISO 8601 whatever the + /// machine thinks - so on those three systems the name of the file and the date in it said + /// different things. + /// + public static string Target(string given, SnapshotMetadata metadata) + { + ArgumentNullException.ThrowIfNull(given); + ArgumentNullException.ThrowIfNull(metadata); + + return given.Length > 0 + ? given + : "bws-snapshot-" + metadata.Machine + "-" + + metadata.TakenAt.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + ".json"; + } + + /// + /// Why this path may not be written to, or nothing when it may. + /// + /// Nothing may replace an existing file without being told to - that has held since + /// 2026-08-02 for a named file and, since 2026-08-03, for the name this tool works out itself. + /// + /// AND ONLY A SNAPSHOT IS EVER REPLACED, TOLD OR NOT, SINCE 2026-10-06 - security report S-8, + /// owner's decision. Everything that is not a snapshot this build reads - another program's + /// file, a snapshot from a schema this build does not know, one that is too large or will not + /// open - is refused and left exactly where it was, not even renamed. + /// + /// A decision and nothing else - it moves nothing and writes nothing. That split is what + /// makes this the single place the rule lives, and the mutation runner asked for it: the check was + /// once in two places, and taking either one away changed no outcome because the other still + /// refused. Two gates for one rule means no single mutation can prove the rule. + /// + /// + /// Whether somebody said a snapshot standing there may go - --force on the command line, + /// the answer to "replace it?" in the window's save dialog. + /// + public static DiskRefusal? WhyNotWritten(string target, bool force) + { + ArgumentNullException.ThrowIfNull(target); + + // A FOLDER IS NEVER WRITTEN OVER, WITH OR WITHOUT --force, since 2026-09-30 - stability report + // D-6, measured: `bws snapshot create ` spent 1545 ms reading signatures and + // then ended with "Access to the path is denied." and code 2, because File.Exists says no for a + // folder and the refusal came only from the write. + if (Directory.Exists(target)) + { + return new DiskRefusal(DiskFault.IsFolder, target, string.Empty); + } + + if (!File.Exists(target)) + { + return null; + } + + if (WhyNotASnapshot(target) is { } why) + { + return why; + } + + if (!force) + { + return new DiskRefusal(DiskFault.AlreadyThere, target, string.Empty); + } + + return null; + } + + /// + /// Reads one snapshot from disk, or says what is wrong with what was pointed at. + /// + /// Deliberately narrow catches rather than a broad one. The ways a path can be wrong are a list + /// somebody can finish - it is not there, it is a directory, it cannot be opened, it is not + /// spellable - which is exactly the argument the broad catches in this project are allowed by, + /// run backwards. + /// + /// Whose fault it was is kept apart - against + /// - because the command line ends the two with different codes: + /// a disk that went away or a file another process holds is the machine failing, and calling it a + /// typo teaches a monitor to ignore the code that says so (docs/12, step 5). + /// + public static bool TryLoad( + string path, [NotNullWhen(true)] out Snapshot? snapshot, [NotNullWhen(false)] out DiskRefusal? refusal) + { + ArgumentNullException.ThrowIfNull(path); + + snapshot = null; + refusal = null; + + string content; + + try + { + // ASKED HOW BIG IT IS BEFORE READING IT ALL, SINCE 2026-09-03 - backlog 308(c). The reader + // below builds one string out of every byte, so a path pointing at something enormous ends + // the process with an out of memory failure. A file that is not there falls through to the + // reader, which produces the sentence about a path somebody got wrong. + if (File.Exists(path) && new FileInfo(path).Length > Biggest) + { + refusal = new DiskRefusal(DiskFault.TooBig, path, string.Empty); + + return false; + } + + content = ReadText(path); + } + + // AHEAD OF THE TWO BELOW, and it has to be: a decoder failure IS an ArgumentException, so the + // clause that calls a mistyped path a mistake would otherwise swallow it and blame the wrong + // thing. + catch (DecoderFallbackException) + { + refusal = new DiskRefusal(DiskFault.NotUtf8, path, string.Empty); + + return false; + } + catch (Exception mistyped) when (mistyped is FileNotFoundException or DirectoryNotFoundException or ArgumentException) + { + refusal = new DiskRefusal(DiskFault.Mistyped, path, mistyped.Message); + + return false; + } + catch (Exception broke) when (broke is IOException or UnauthorizedAccessException) + { + refusal = new DiskRefusal(DiskFault.Unreadable, path, broke.Message); + + return false; + } + + // The reader's own out value carries no promise about null, so the promise this method makes + // is kept here rather than asserted with an exclamation mark - a document it calls read and + // hands back as nothing would otherwise reach a caller the annotation told not to check. + if (SnapshotJson.TryRead(content, out var read, out var failure) && read is not null) + { + snapshot = read; + + return true; + } + + // The reason is never null when the read failed, and saying so out loud is cheaper than a + // nullable sentence in a message. + refusal = new DiskRefusal(DiskFault.NotASnapshot, path, failure ?? string.Empty); + + return false; + } + + /// + /// Why what is already at this path is not a snapshot this build reads, or nothing when it is one. + /// + /// The same faults names, because it is the same question about the + /// same kind of file - asked here of something standing where a snapshot is about to go. + /// + /// THE SIZE IS ASKED BEFORE A BYTE IS READ, and until 2026-10-06 it was not. Measured that + /// day with the shipped 0.3.0: snapshot create over a 1.5 GB file with --force ended after + /// 4045 ms with code 1 and "Insufficient memory to continue the execution of the program." + /// + private static DiskRefusal? WhyNotASnapshot(string path) + { + try + { + if (new FileInfo(path).Length > Biggest) + { + return new DiskRefusal(DiskFault.TooBig, path, string.Empty); + } + + return SnapshotJson.TryRead(ReadText(path), out _, out var failure) + ? null + : new DiskRefusal(DiskFault.NotASnapshot, path, failure ?? string.Empty); + } + + // Ahead of the clause below for the reason TryLoad gives: a decoder failure IS an ArgumentException. + catch (DecoderFallbackException) + { + return new DiskRefusal(DiskFault.NotUtf8, path, string.Empty); + } + catch (Exception unreadable) when (unreadable is IOException or UnauthorizedAccessException or ArgumentException) + { + return new DiskRefusal(DiskFault.Unreadable, path, unreadable.Message); + } + } + + /// + /// The bytes at a path, as text, or nothing at all. + /// + /// A decoder that refuses, and until 2026-08-03 this was File.ReadAllText, which + /// substitutes. That call honours a byte order mark and otherwise assumes UTF-8, and when the + /// bytes are not UTF-8 it puts a replacement character in and carries on without a word. + /// + /// MEASURED on the same snapshot re-encoded four ways and compared against itself: + /// + /// + /// UTF-8 No differences. + /// UTF-8 with a mark No differences. + /// UTF-16 with a mark No differences. + /// Windows-1250 Changed (297) + /// + /// + /// Two hundred and ninety seven entries reported as changed, and not one of them had. The + /// mark is still honoured, so a snapshot saved as UTF-16 by something else still reads. Only bytes + /// that are not any of those come back as a refusal. + /// + /// The bytes are not text this build can read. + private static string ReadText(string path) + { + using var stream = File.OpenRead(path); + using var reader = new StreamReader( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), + detectEncodingFromByteOrderMarks: true); + + return reader.ReadToEnd(); + } +} diff --git a/src/Bws.Gui/ActionBar.xaml b/src/Bws.Gui/ActionBar.xaml index 248d18b..a894dbc 100644 --- a/src/Bws.Gui/ActionBar.xaml +++ b/src/Bws.Gui/ActionBar.xaml @@ -164,6 +164,28 @@ Margin="{StaticResource MarginBetweenControls}" Click="ExportAsked" /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bws.Gui/DiffTable.xaml.cs b/src/Bws.Gui/DiffTable.xaml.cs new file mode 100644 index 0000000..2b9c75f --- /dev/null +++ b/src/Bws.Gui/DiffTable.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows.Controls; + +namespace Bws.Gui; + +/// The table under a comparison, or the sentence in its place - the markup says the rest. +public partial class DiffTable : UserControl +{ + public DiffTable() + { + InitializeComponent(); + } +} diff --git a/src/Bws.Gui/DiffView.xaml b/src/Bws.Gui/DiffView.xaml new file mode 100644 index 0000000..5e278be --- /dev/null +++ b/src/Bws.Gui/DiffView.xaml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + private void ArrangeTheMiddle() { - var overview = _model.ShowingOverview; + // FOUR CONTROLS SINCE 2026-10-14 - a comparison with a snapshot takes the middle the way the + // overview does (MainWindow.Snapshots.cs). It arrives only from the action bar, which the + // overview hides, and it puts the overview away when it comes - so the two never stand there + // together, and if they ever did the comparison is the newer thing somebody asked for. + var comparing = _model.Snapshotting.Shown is not null; + var overview = _model.ShowingOverview && !comparing; + var elsewhere = overview || comparing; Overview.Visibility = overview ? Visibility.Visible : Visibility.Collapsed; - Entries.Visibility = overview ? Visibility.Collapsed : Visibility.Visible; + Comparison.Visibility = comparing ? Visibility.Visible : Visibility.Collapsed; + Entries.Visibility = elsewhere ? Visibility.Collapsed : Visibility.Visible; // THE EMPTY STATE IS PUT BACK BY CLEARING RATHER THAN BY SETTING, AND THE DIFFERENCE IS A // FAULT THIS METHOD SHIPPED FOR ONE BUILD. That control decides its own visibility from a @@ -98,7 +105,7 @@ private void ArrangeTheMiddle() // Clearing hands the property back to the style, which is the only thing that knows whether // there is anything to say. Setting Collapsed is still a local value and that is correct: // while the overview has the middle of the window, nothing about a list is worth saying. - if (overview) + if (elsewhere) { ListEmptyState.Visibility = Visibility.Collapsed; @@ -142,7 +149,7 @@ private void ArrangeTheMiddle() /// private void ArrangeTheChrome() { - var chrome = _model.ShowingOverview ? Visibility.Collapsed : Visibility.Visible; + var chrome = _model.ListOnScreen ? Visibility.Visible : Visibility.Collapsed; Scope.Visibility = chrome; Search.Visibility = chrome; diff --git a/src/Bws.Gui/MainWindow.Suggesting.cs b/src/Bws.Gui/MainWindow.Suggesting.cs index 2980089..4cc657a 100644 --- a/src/Bws.Gui/MainWindow.Suggesting.cs +++ b/src/Bws.Gui/MainWindow.Suggesting.cs @@ -64,7 +64,7 @@ internal void GiveTheBoxTheKeyboard() => System.Windows.Threading.DispatcherPriority.Input, () => { - if (_model.ShowingOverview) + if (!_model.ListOnScreen) { return; } diff --git a/src/Bws.Gui/MainWindow.xaml b/src/Bws.Gui/MainWindow.xaml index 5c03352..91e0eb1 100644 --- a/src/Bws.Gui/MainWindow.xaml +++ b/src/Bws.Gui/MainWindow.xaml @@ -326,6 +326,13 @@ x:Name="Overview" Visibility="Collapsed" /> + + + 640 - 900 + 1000