diff --git a/.github/requirements-semgrep.txt b/.github/requirements-semgrep.txt index 136b999..32359a9 100644 --- a/.github/requirements-semgrep.txt +++ b/.github/requirements-semgrep.txt @@ -12,23 +12,30 @@ # 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. Measured again on 2026-10-14, when 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. That pull request's own Semgrep run, +# on the merge of commit c8ea3cb into main with `--config p/default`: semgrep ran 374 rules over +# 399 files and reported zero findings, and the gate read zero errors at level error and 419 rules. +# The two rule counts are one run read two ways - 374 is semgrep's own summary, 419 the timing +# list in the report, which is what the gate counts. The run on the commit before, d39889d, gave +# the same numbers. Before them the numbers here were from 1.177.0 on 2026-09-22 (131 rules, 301 +# files), and 1.178.0 was never measured. 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/, -# site/, .github/ and the root - about three hundred files - and a collapse to a handful is the -# shape of a scan that ran somewhere else. The gate prints the number on every run for exactly -# that reason. +# 399 OF 743 FILES, AND THAT IS NOT A FAULT. Semgrep's own default ignore patterns exclude +# tests/, which is 336 of this repository's files, one picture is skipped for being over 1 MB, and +# the seven other pictures are not among the 399 either - that last part is the arithmetic of the +# count, not a line semgrep prints. So a healthy run of this tree reads src/, site/, .github/, +# packaging/ and the root - about four hundred files - and a collapse to a handful is the 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/.github/scripts/semgrep_gate.py b/.github/scripts/semgrep_gate.py index 7ed18c5..ce35a76 100644 --- a/.github/scripts/semgrep_gate.py +++ b/.github/scripts/semgrep_gate.py @@ -45,8 +45,10 @@ A scan error blocks too. A rule that failed to run is not a rule that found nothing, and "the scan was green" must never mean "the scan did not happen". -Only entries at level "error" count: a clean run of this repository carries four -entries at level "warn", and none of them is a reason to stop anybody. +Only entries at level "error" count: a clean run of this repository carried four +entries at level "warn" with 1.177.0 on 2026-09-22, and none of them is a reason +to stop anybody. That count was not taken again with 1.180.0, because this gate +prints only the level it blocks on. A scan that read too few files blocks as well, and so does one carrying too few rules. Both numbers are printed on every run rather than only when they fire. @@ -62,17 +64,24 @@ What a healthy run of THIS repository looks like ------------------------------------------------- -Measured 2026-09-22 against commit 693bced, exported to a clean directory so -that exactly the 516 files a checkout carries were present, semgrep 1.177.0 with -`--config p/default`: +Measured 2026-10-14 by this repository's own Semgrep run on pull request 61, +the merge of commit c8ea3cb into main, semgrep 1.180.0 with `--config p/default`. +This gate's last line on that run: - 131 rules, 301 files, 0 findings, 0 errors at level error, 4 at level warn + semgrep: 0 blocking, 0 other, 0 scan error(s), 399 file(s) scanned, 419 rule(s) -301 rather than 516 because semgrep's own default ignore patterns exclude -tests/, which is 212 of this repository's files. That is not a fault and not a -setting of ours - it is the tool's default, and it is worth knowing before -somebody reads "301" as a broken scan. The files that ARE read are src/, site/, -.github/ and the root. +Semgrep's own summary on the same run says 374 rules. Both are true - the gate +counts the timing list in the report, which is the longer of the two. + +399 rather than the 743 files a checkout carries, because semgrep's own default +ignore patterns exclude tests/, which is 336 of them, and the eight pictures are +not read either. That is not a fault and not a setting of ours - it is the +tool's default, and it is worth knowing before somebody reads "399" as a broken +scan. The files that ARE read are src/, site/, .github/, packaging/ and the root. + +The first measurement, 2026-09-22 with 1.177.0 against commit 693bced exported +to a clean directory, was 131 rules over 301 files - before the two Python +scripts arrived, which the floors below come back to. Findings decided once and recorded ----------------------------------- @@ -100,9 +109,15 @@ # a gate that goes red for a legitimate reason is a gate people learn to bypass. Measured on # this repository: # -# files 301 (before the Python scripts existed), 306, 309 - it tracks the tree +# files 301 (before the Python scripts existed), 306, 309, 399 - it tracks the tree # rules 131, 374, 419 - it tracks WHICH LANGUAGES ARE IN THE TREE, not the ruleset alone. -# Two .py files took it from 131 to 374 by pulling in Python's rules. +# Two .py files took it from 131 to 374 by pulling in Python's rules. On 2026-10-14, +# with 1.180.0, ONE run said both 374 (semgrep's summary) and 419 (the timing list +# this gate counts), so the last two may be one run read two ways - the earlier runs +# did not keep both, and that is not settled. +# +# Both floors were checked against that run: 399 files over 200, 419 rules over 50. Still floors +# against a collapse, far under a healthy run on purpose. # # So these refuse a collapse and say nothing about a drift of tens. Raising them is a deliberate # act that means measuring again, exactly like the pinned scanner version. 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