diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7b12f..18c4918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,40 @@ is not part of this repository. ### Changed +- **Breaking for scripts reading `--json`:** every word-valued field in the JSON of a plan and of a + comparison is now spelled the way the listing and the snapshot spell theirs - `"outcome": + "Succeeded"` beside `"status": "Running"`, where one result used to say `"succeeded"` beside + `"Running"`. Field names are unchanged. What changed: + + | Field | Before | Now | + |---|---|---| + | `action` | `stop`, `start`, `restart`, `setStartType`, `forceStop`, `forceRestart` | `Stop`, `Start`, `Restart`, `SetStartType`, `ForceStop`, `ForceRestart` | + | `operation` | `stop`, `start`, `setStartType`, `terminate` | `Stop`, `Start`, `SetStartType`, `Terminate` | + | `reason` | `requested`, `cascade`, `sharesTheProcess`, `restore`, `escalation` | `Requested`, `Cascade`, `SharesTheProcess`, `Restore`, `Escalation` | + | `outcome` | `succeeded`, `failed`, `timedOut`, `skipped` | `Succeeded`, `Failed`, `TimedOut`, `Skipped` | + | `skippedBecause` | `alreadyThere`, `earlierStepFailed`, `cancelled`, `processStays`, `nothingToPutBack` | `AlreadyThere`, `EarlierStepFailed`, `Cancelled`, `ProcessStays`, `NothingToPutBack` | + | `kind` of a warning | `cascade`, `dependentsInTheWay`, `sharedProcess` and the rest | `Cascade`, `DependentsInTheWay`, `SharedProcess` and the rest - the first letter raised on every one | + | `group` in `snapshot diff --json` | `configuration`, `runningState` | `Configuration`, `RunningState` | + + PowerShell compares text without case by default, so `$step.outcome -eq 'succeeded'` keeps + working. A comparison that is case-sensitive - `-ceq`, `jq`, most other languages - needs the new + spelling. +- `snapshot diff --exit-code` ends with 5 only when the configuration differs: an entry added or + removed, or one set up differently. A service that only stopped or started by itself between the + two snapshots is still reported, under "Changed" and marked as running state, and no longer makes + a nightly check fail. `"differs"` in `--json` answers the same way. +- Per-user service copies - the ones Windows makes for each signed-in session, named like + `CDPUserSvc_1036d1` - are left out of a comparison on both sides and counted in one line at the + top, and in `"instancesLeftOut"` in `--json`. They come and go with the people signed in, so they + used to appear as added and removed every time. The template they are made from is still compared. +- A snapshot now records Windows down to the monthly update (`operatingSystemVersion`, for example + `10.0.26200.9550`) and the language the service manager names things in (`namesLanguage`). The + comparison says when the two were taken on different updates, when they were taken by different + accounts, and when the names are in different languages - and then leaves display names and + descriptions out, instead of reporting every translated one as changed. Snapshots are now schema + version 5. Files written by 0.3.0 (version 4) are still read and compared, with a line saying they + do not record the update or the language. A file written by this version is refused by 0.3.0. + - The warning that Windows starts a service again once its process is ended now says when, beside every name: "Spooler (5 s later)", or "W32Time (60 s or 120 s later)" when the recovery actions name several delays - which of them applies depends on how often the service has failed, and @@ -111,7 +145,7 @@ is not part of this repository. those only if the entry did not stop. A service sharing the process that refused to stop used to make the plan skip the entry's own polite stop and end the process at once, and when the entry would have stopped on its own, the others had been stopped for nothing. In the JSON of a run, the - steps not needed are reported with `"skippedBecause": "processStays"`, and the run still counts as + steps not needed are reported with `"skippedBecause": "ProcessStays"`, and the run still counts as completed. - The preview of a force stop names a critical service arriving with `--dependents`, a service sharing the process that does not accept a stop, and one that is disabled and could not be @@ -146,11 +180,11 @@ is not part of this repository. - The preview of a force stop says when Windows will start a service living in the process again by itself once the process is ended, when it will run a program named in a service's recovery actions, and when a service has a recovery action of a kind the tool cannot name. In the JSON of a - plan these are the warnings `recoveryRestarts`, `recoveryRunsProgram` and `recoveryUnnamed`. Until + plan these are the warnings `RecoveryRestarts`, `RecoveryRunsProgram` and `RecoveryUnnamed`. Until now `bws kill` reported such a service stopped while Windows was already starting it again. - A force stop whose service Windows starts again at once is reported straight away as the process ended and the service running again, with its new process, instead of after the whole limit as - having run out of time. In the JSON of a run that step is `"outcome": "failed"` with `errorCode` 0. + having run out of time. In the JSON of a run that step is `"outcome": "Failed"` with `errorCode` 0. - Just before the process is ended, a force stop looks at it once more. If a service has started inside it since the preview, or a running service outside it has started to depend on something inside it, the process is not ended and the step says why. @@ -158,15 +192,21 @@ is not part of this repository. not running. A run now starts again only what it stopped itself: after an interruption, after a stop that was refused, and for a dependent somebody else stopped between the preview and the run, the step says "not started, this run never stopped it" and `skippedBecause` in the JSON of - a run is `nothingToPutBack`. The same goes for a restart of a whole selection interrupted half + a run is `NothingToPutBack`. The same goes for a restart of a whole selection interrupted half way. A service stopped by somebody else after the preview of its own restart is left stopped, and the run says it did not end where the plan wanted it. - Restarting a service that is not running only starts it, from the window and with `bws restart`, and the preview says so - one step and the warning "is not running, so - restarting it only starts it" (`restartOnlyStarts` in the JSON). Until now the preview showed a + restarting it only starts it" (`RestartOnlyStarts` in the JSON). Until now the preview showed a stop and a start. A disabled service that is not running is no longer refused with a sentence about stopping it - the plan warns that Windows will refuse to start it, the same as a plan to start it. +- `snapshot diff` on a snapshot with an empty name in an entry's list of fields nobody read says + which entry is damaged and ends with code 2, instead of "Object reference not set to an instance + of an object." and code 1. +- `snapshot create` given a folder instead of a file name says so at once and ends with code 2. It + used to verify every signature on the machine first and then answer "Access to the path is + denied." ## [0.3.0] - 2026-09-25 diff --git a/README.md b/README.md index eed89c5..577d7bd 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ bws stop Winmgmt --dry-run --dependents what stopping it would take do bws kill Spooler --dry-run what ending its process would take with it bws start-type Spooler manual --dry-run what taking it off automatic would do bws snapshot create before.json freeze the machine before a change -bws snapshot diff before.json --live --exit-code what has changed since, 5 if anything has +bws snapshot diff before.json --live --exit-code what has changed since, 5 if the setup has ``` A preview is the plan, printed: @@ -237,8 +237,11 @@ session had no rights to it, comes out as *not compared* under its entry, and th those entries, so a diff that says *nothing changed* means nothing changed in what both sides could see. -`--exit-code` ends with `5` when anything differs, so a scheduled task can take a snapshot at -deployment and page somebody the first night the machine drifts. Compare two files instead of a +`--exit-code` ends with `5` when the configuration differs - an entry added or removed, or one set +up differently - so a scheduled task can take a snapshot at deployment and page somebody the first +night the machine drifts. A service that only stopped or started by itself is reported and does +not count, and neither do the per-user copies Windows makes for each signed-in session: those are +left out of the comparison and counted in a line of their own. Compare two files instead of a file and the machine to answer *what did the Tuesday patch do*, or *why does staging differ from production* - take one on each and diff them anywhere. diff --git a/site/i18n/en.json b/site/i18n/en.json index 04f458a..ac45c30 100644 --- a/site/i18n/en.json +++ b/site/i18n/en.json @@ -38,7 +38,7 @@ "exit.usage": "The command line was wrong. A mistyped command is offered the one you probably meant.", "exit.incomplete": "The plan was good, it ran, and something in it did not get where it was going - the manager refused, or the session had no rights to it.", "exit.interrupted": "Somebody stopped the run by hand. Non-zero even when every step still arrived, so a wrapper does not treat an interrupted run as clean.", - "exit.differences": "A comparison ran and found differences. Only with --exit-code, because drift is what this tool is for finding, and finding it is not a failure.", + "exit.differences": "A comparison ran and found the configuration drifted - an entry added or removed, or one set up differently. Running state alone is not drift. Only with --exit-code, because drift is what this tool is for finding, and finding it is not a failure.", "switch.query": "Narrow the listing with the query language.", "switch.signatures": "Read who signed each binary and whether Windows trusts it. Several seconds over a whole machine, so it is off unless asked - and a query about signatures turns it on by itself.", @@ -52,7 +52,7 @@ "switch.components": "On license, turn the notice into every component inside this executable with its version, its licence and where it came from. The same set the bill of materials published beside the download carries, because both are rendered from one register.", "switch.json": "The same document, machine readable, on standard output.", "switch.note": "What the snapshot was taken for, kept inside the file.", - "switch.exit-code": "End with code 5 when anything differs. Off by default, so a script that only wants the differences printed is not tripped by finding some.", + "switch.exit-code": "End with code 5 when the configuration differs - an entry added or removed, or one set up differently. Running state alone does not count. Off by default, so a script that only wants the differences printed is not tripped by finding some.", "switch.live": "Compare the file against this machine as it is now, rather than against a second file.", "switch.timing": "How long each part of the read took, on standard error.", "switch.dry-run": "Print the plan and change nothing. It is the same plan an execution runs - there is no second code path for the real thing.", diff --git a/site/i18n/pl.json b/site/i18n/pl.json index 2a06643..df73e40 100644 --- a/site/i18n/pl.json +++ b/site/i18n/pl.json @@ -38,7 +38,7 @@ "exit.usage": "Wiersz poleceń był zły. Przy literówce w komendzie program podpowiada tę, o którą prawdopodobnie chodziło.", "exit.incomplete": "Plan był dobry, wykonał się, a coś w nim nie dotarło tam, gdzie miało - menedżer odmówił albo sesja nie miała do tego praw.", "exit.interrupted": "Ktoś zatrzymał przebieg ręcznie. Niezerowy nawet wtedy, gdy każdy krok i tak dotarł, żeby skrypt nadrzędny nie potraktował przerwanego przebiegu jako czystego.", - "exit.differences": "Porównanie wykonało się i znalazło różnice. Tylko z --exit-code, bo dryf jest tym, czego to narzędzie szuka, a znalezienie go nie jest porażką.", + "exit.differences": "Porównanie wykonało się i znalazło dryf konfiguracji - wpis doszedł albo zniknął, albo jest ustawiony inaczej. Sam stan pracy nie jest dryfem. Tylko z --exit-code, bo dryf jest tym, czego to narzędzie szuka, a znalezienie go nie jest porażką.", "switch.query": "Zawęża listę językiem zapytań.", "switch.signatures": "Czyta, kto podpisał każdy plik i czy Windows temu podpisowi ufa. Kilka sekund na całej maszynie, więc jest wyłączone, dopóki nie poprosisz - a zapytanie o podpisy włącza to samo.", @@ -52,7 +52,7 @@ "switch.components": "Przy license zamienia notę w pełną listę: każdy składnik wewnątrz tego pliku wykonywalnego, z wersją, licencją i adresem źródeł. Ten sam zestaw, który niesie spis składników publikowany obok pobrania, bo oba powstają z jednego rejestru.", "switch.json": "Ten sam dokument, czytelny dla maszyny, na wyjściu standardowym.", "switch.note": "Po co snapshot został zrobiony - zapisane w środku pliku.", - "switch.exit-code": "Kończy kodem 5, gdy cokolwiek się różni. Domyślnie wyłączone, żeby skrypt, który chce tylko zobaczyć różnice, nie wywracał się na tym, że je znalazł.", + "switch.exit-code": "Kończy kodem 5, gdy różni się konfiguracja - wpis doszedł albo zniknął, albo jest ustawiony inaczej. Sam stan pracy się nie liczy. Domyślnie wyłączone, żeby skrypt, który chce tylko zobaczyć różnice, nie wywracał się na tym, że je znalazł.", "switch.live": "Porównuje plik z tą maszyną w jej obecnym stanie, zamiast z drugim plikiem.", "switch.timing": "Ile trwała każda część odczytu, na wyjściu błędów.", "switch.dry-run": "Drukuje plan i nie zmienia niczego. To ten sam plan, który wykonuje się przy wykonaniu - nie ma drugiej ścieżki kodu dla wersji prawdziwej.", diff --git a/site/pages/audit-windows-services/en.html b/site/pages/audit-windows-services/en.html index 9740345..aa7e6f2 100644 --- a/site/pages/audit-windows-services/en.html +++ b/site/pages/audit-windows-services/en.html @@ -41,8 +41,8 @@

The nightly shape

# every night, from a scheduled task bws snapshot diff C:\baselines\web01.json --live --exit-code --json > C:\logs\drift.json -# 0 - nothing differs -# 5 - something differs, and drift.json says what +# 0 - no configuration drift +# 5 - the configuration drifted, and drift.json says what # 3 - it ran and could not read everything, so the answer is partial

Exit code 5 is what makes this an audit rather than a report nobody reads: the task is silent until the machine drifts, and the night it does, somebody is paged.

Take the baseline from an elevated session. Without administrator rights the manager lists fewer entries and refuses more of what it lists. The snapshot records whether the account that took it was an administrator, so the comparison can say that rather than reporting entries as removed that nobody removed - but a baseline missing half the machine is still a baseline missing half the machine.

diff --git a/site/pages/audit-windows-services/pl.html b/site/pages/audit-windows-services/pl.html index 1dd9798..19622e6 100644 --- a/site/pages/audit-windows-services/pl.html +++ b/site/pages/audit-windows-services/pl.html @@ -41,8 +41,8 @@

Nocny kształt

# co noc, z zadania harmonogramu bws snapshot diff C:\baselines\web01.json --live --exit-code --json > C:\logs\drift.json -# 0 - nic się nie różni -# 5 - coś się różni, a drift.json mówi co +# 0 - konfiguracja się nie rozjechała +# 5 - konfiguracja się rozjechała, a drift.json mówi co # 3 - wykonało się i nie wszystko dało się odczytać, więc odpowiedź jest częściowa

Kod wyjścia 5 jest tym, co robi z tego audyt, a nie raport, którego nikt nie czyta: zadanie milczy, dopóki maszyna się nie rozjedzie, a tej nocy, w której się rozjedzie, ktoś zostaje wezwany.

Punkt odniesienia rób z sesji elewowanej. Bez praw administratora menedżer wymienia mniej wpisów i odmawia częściej. Snapshot zapisuje, czy konto, które go zrobiło, było administratorem, więc porównanie może to powiedzieć, zamiast zgłaszać jako usunięte wpisy, których nikt nie usunął - ale punkt odniesienia bez połowy maszyny nadal jest punktem odniesienia bez połowy maszyny.

diff --git a/site/pages/cli-reference/en.html b/site/pages/cli-reference/en.html index a786747..97ec479 100644 --- a/site/pages/cli-reference/en.html +++ b/site/pages/cli-reference/en.html @@ -97,8 +97,8 @@

In a scheduled task

# every night, from a scheduled task bws snapshot diff C:\baselines\web01.json --live --exit-code --json > C:\logs\drift.json -# 0 - nothing differs -# 5 - something differs, and drift.json says what +# 0 - no configuration drift +# 5 - the configuration drifted, and drift.json says what # 3 - it ran and could not read everything, so the answer is partial

The window is the other half of the same engine: {{archive_window}} holds it, and the download page says what each archive is.

diff --git a/site/pages/cli-reference/pl.html b/site/pages/cli-reference/pl.html index 66752b9..ee422c2 100644 --- a/site/pages/cli-reference/pl.html +++ b/site/pages/cli-reference/pl.html @@ -90,8 +90,8 @@

W zadaniu harmonogramu

# co noc, z zadania harmonogramu bws snapshot diff C:\baselines\web01.json --live --exit-code --json > C:\logs\drift.json -# 0 - nic się nie różni -# 5 - coś się różni, a drift.json mówi co +# 0 - konfiguracja się nie rozjechała +# 5 - konfiguracja się rozjechała, a drift.json mówi co # 3 - wykonało się i nie wszystko dało się odczytać, więc odpowiedź jest częściowa

Okno jest drugą połową tego samego silnika: niesie je {{archive_window}}, a strona pobierania mówi, co jest w którym archiwum.

diff --git a/site/pages/home/en.html b/site/pages/home/en.html index bae36c1..a5e3b63 100644 --- a/site/pages/home/en.html +++ b/site/pages/home/en.html @@ -121,7 +121,7 @@

What changed since yesterday

  • Configuration and running state are reported apart. Two snapshots taken a day apart differ in what happens to be running and almost none of it is drift - the start type that changed is the line to read.
  • What could not be compared is listed, not hidden. A field one snapshot never read comes out as not compared, so a diff that says nothing changed means nothing changed in what both sides could see.
  • -
  • --exit-code ends with 5 when anything differs, so a scheduled task can take a snapshot at deployment and page somebody the first night the machine drifts.
  • +
  • --exit-code ends with 5 when the configuration differs, so a scheduled task can take a snapshot at deployment and page somebody the first night the machine drifts.
  • Two machines, two files. Take a snapshot on staging and one on production, and diff them anywhere.
@@ -167,7 +167,7 @@

From the command line

bws kill Spooler --dry-run what ending its process would take with it bws start-type Spooler manual --dry-run what taking it off automatic would do bws snapshot create before.json freeze the machine before a change -bws snapshot diff before.json --live --exit-code what has changed since, 5 if anything has +bws snapshot diff before.json --live --exit-code what has changed since, 5 if the setup has Every command, switch and exit code diff --git a/site/pages/home/pl.html b/site/pages/home/pl.html index 19b2066..45f6c4f 100644 --- a/site/pages/home/pl.html +++ b/site/pages/home/pl.html @@ -109,7 +109,7 @@

Co zmieniło się od wczoraj

  • Konfiguracja i stan są zgłaszane osobno. Dwa snapshoty z odstępem doby różnią się tym, co akurat działa, i prawie nic z tego nie jest dryfem - linią do przeczytania jest zmieniony typ uruchomienia.
  • To, czego nie dało się porównać, jest wypisane, a nie ukryte. Pole, którego jeden ze snapshotów nigdy nie odczytał, wychodzi jako nieporównane - więc różnica mówiąca nic się nie zmieniło znaczy, że nic się nie zmieniło w tym, co obie strony widziały.
  • -
  • --exit-code kończy kodem 5, gdy cokolwiek się różni - więc zadanie harmonogramu może zrobić snapshot przy wdrożeniu i wezwać kogoś pierwszej nocy, w której maszyna się rozjedzie.
  • +
  • --exit-code kończy kodem 5, gdy różni się konfiguracja - więc zadanie harmonogramu może zrobić snapshot przy wdrożeniu i wezwać kogoś pierwszej nocy, w której maszyna się rozjedzie.
  • Dwie maszyny, dwa pliki. Zrób snapshot na testowej i na produkcyjnej, a potem porównaj je gdziekolwiek.
@@ -155,7 +155,7 @@

Z wiersza poleceń

bws kill Spooler --dry-run co zabrałoby zakończenie procesu bws start-type Spooler manual --dry-run co da zdjęcie z automatu bws snapshot create before.json zamroź maszynę przed zmianą -bws snapshot diff before.json --live --exit-code co się zmieniło, 5 jeśli cokolwiek +bws snapshot diff before.json --live --exit-code co się zmieniło, 5 jeśli konfiguracja Każda komenda, przełącznik i kod wyjścia diff --git a/site/pages/snapshots-and-drift/en.html b/site/pages/snapshots-and-drift/en.html index a0276d2..ada2481 100644 --- a/site/pages/snapshots-and-drift/en.html +++ b/site/pages/snapshots-and-drift/en.html @@ -48,14 +48,14 @@

Then ask what is different

The exit code is the point

-

--exit-code ends with 5 when anything differs. Not a failure - drift is what this tool is for finding, and finding it is not a failure - which is why it has a number of its own rather than borrowing the one that means something went wrong.

+

--exit-code ends with 5 when the configuration differs - an entry added or removed, or one set up differently. A service that only stopped or started by itself is reported and does not count, and neither do the per-user copies Windows makes for each signed-in session, which are left out and counted. Not a failure - drift is what this tool is for finding, and finding it is not a failure - which is why it has a number of its own rather than borrowing the one that means something went wrong.

# the day the machine goes into service
 bws snapshot create C:\baselines\web01.json --note "after the build"
 
 # every night, from a scheduled task
 bws snapshot diff C:\baselines\web01.json --live --exit-code --json > C:\logs\drift.json
-#   0 - nothing differs
-#   5 - something differs, and drift.json says what
+#   0 - no configuration drift
+#   5 - the configuration drifted, and drift.json says what
 #   3 - it ran and could not read everything, so the answer is partial

That is the whole mechanism: a baseline taken when the machine was known good, and a question asked every night. The first night it answers 5, somebody finds out on the first night rather than on the morning something breaks.

diff --git a/site/pages/snapshots-and-drift/pl.html b/site/pages/snapshots-and-drift/pl.html index 78fd3b5..c95dfe6 100644 --- a/site/pages/snapshots-and-drift/pl.html +++ b/site/pages/snapshots-and-drift/pl.html @@ -48,14 +48,14 @@

A potem zapytaj, co jest inaczej

Kod wyjścia jest tu sednem

-

--exit-code kończy kodem 5, gdy cokolwiek się różni. To nie jest porażka - dryf jest tym, czego to narzędzie szuka, a znalezienie go nie jest porażką - i dlatego ma własny numer, zamiast pożyczać ten, który znaczy, że coś poszło źle.

+

--exit-code kończy kodem 5, gdy różni się konfiguracja - wpis doszedł albo zniknął, albo jest ustawiony inaczej. Usługa, która tylko sama się zatrzymała albo uruchomiła, jest w raporcie i się nie liczy, podobnie jak kopie per-użytkownik, które Windows robi dla każdej zalogowanej sesji - te są pomijane i policzone. To nie jest porażka - dryf jest tym, czego to narzędzie szuka, a znalezienie go nie jest porażką - i dlatego ma własny numer, zamiast pożyczać ten, który znaczy, że coś poszło źle.

# w dniu, w którym maszyna wchodzi do pracy
 bws snapshot create C:\baselines\web01.json --note "after the build"
 
 # co noc, z zadania harmonogramu
 bws snapshot diff C:\baselines\web01.json --live --exit-code --json > C:\logs\drift.json
-#   0 - nic się nie różni
-#   5 - coś się różni, a drift.json mówi co
+#   0 - konfiguracja się nie rozjechała
+#   5 - konfiguracja się rozjechała, a drift.json mówi co
 #   3 - wykonało się i nie wszystko dało się odczytać, więc odpowiedź jest częściowa

To cały mechanizm: punkt odniesienia wzięty, gdy maszyna była dobra, i pytanie zadawane co noc. Pierwszej nocy, w której odpowiedź brzmi 5, ktoś się o tym dowiaduje - zamiast rano tego dnia, w którym coś przestanie działać.

diff --git a/src/Bws.Cli/DiffJson.cs b/src/Bws.Cli/DiffJson.cs index 69c41a6..7679f27 100644 --- a/src/Bws.Cli/DiffJson.cs +++ b/src/Bws.Cli/DiffJson.cs @@ -20,7 +20,14 @@ internal static class DiffJson { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, - Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + + // KEYS camelCase, VALUES the way the enumeration spells them - rule 5 of docs/03, which the + // snapshot has followed since 2026-08-26 and this document did not until 2026-09-30 + // (stability report D-6 and round 2 point 8, owner's decision, a breaking change in 0.x). Until then the naming + // policy was handed to the converter as well, so "group" said "configuration" while every + // value in the snapshot beside it said "OwnProcess" and "Running". A script reading both + // had to know which document was in which convention. + Converters = { new JsonStringEnumConverter() }, // Characters as themselves. Display names on this machine are translated, and a // pipeline comparing them against anything would otherwise be comparing escapes. @@ -30,18 +37,22 @@ internal static class DiffJson internal static string Render(SnapshotDiff diff) => JsonSerializer.Serialize( new DiffDocument( - diff.Any, + diff.Drifted, new CaveatsDocument( diff.Caveats.ElevationDiffers, diff.Caveats.MachineDiffers, diff.Caveats.OperatingSystemDiffers, - diff.Caveats.ToolVersionDiffers), + diff.Caveats.ToolVersionDiffers, + diff.Caveats.LanguageDiffers, + diff.Caveats.AccountDiffers, + diff.Caveats.NotKnown), [.. diff.Added.Select(Presence)], [.. diff.Removed.Select(Presence)], [.. diff.Uncertain.Select(Presence)], [.. diff.Changed.Select(Changed)], [.. diff.NotFullyCompared.Select(Changed)], - diff.NeitherRead), + diff.NeitherRead, + new LeftOutDocument(diff.LeftOut.Earlier, diff.LeftOut.Later)), Options); private static PresenceDocument Presence(EntryPresence entry) => @@ -58,13 +69,19 @@ private static ChangedDocument Changed(ChangedEntry entry) => /// /// One boolean so a step does not have to add four lists up to find out whether anything /// was found. It answers the same question --exit-code answers, for whoever is reading - /// the document rather than the code. + /// the document rather than the code - and since 2026-09-30 that question is about + /// CONFIGURATION: an entry that differs only in running state is still under + /// and no longer makes this true (stability report D-2). /// /// /// Nothing differed, and something could not be looked at. Apart from /// on purpose: a step that treated these as drift would fail /// over one snapshot having been taken without elevation. /// + /// + /// 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. + /// private sealed record DiffDocument( bool Differs, CaveatsDocument Caveats, @@ -73,13 +90,25 @@ private sealed record DiffDocument( IReadOnlyList Uncertain, IReadOnlyList Changed, IReadOnlyList NotFullyCompared, - IReadOnlyList NeitherRead); + IReadOnlyList NeitherRead, + LeftOutDocument InstancesLeftOut); + /// + /// Metadata one of the two files does not carry, as the field names the file uses - a + /// version four snapshot names both fields version five added. A flag beside a name here is + /// false because nobody could tell, not because the two agree. The three fields after the + /// first four were added 2026-09-30 (D-5), so nothing a script already reads changed type. + /// private sealed record CaveatsDocument( bool ElevationDiffers, bool MachineDiffers, bool OperatingSystemDiffers, - bool ToolVersionDiffers); + bool ToolVersionDiffers, + bool LanguageDiffers, + bool AccountDiffers, + IReadOnlyList NotKnown); + + private sealed record LeftOutDocument(int Earlier, int Later); private sealed record PresenceDocument(string ServiceName, string DisplayName); diff --git a/src/Bws.Cli/DiffText.cs b/src/Bws.Cli/DiffText.cs index 73b9682..b5db244 100644 --- a/src/Bws.Cli/DiffText.cs +++ b/src/Bws.Cli/DiffText.cs @@ -21,9 +21,13 @@ internal static string Render(SnapshotDiff diff) // What makes the comparison less than exact goes first, not last. Somebody who reads // the differences before learning that one side was taken without elevation has // already believed something. - Caveats(text, diff.Caveats, diff.NeitherRead); + Caveats(text, diff); - if (!diff.Any && diff.Uncertain.Count == 0 && diff.NotFullyCompared.Count == 0) + // WHETHER THERE IS ANYTHING TO SHOW, which is not whether anything drifted - since + // 2026-09-30 those are two questions. An entry that differs only in running state does not + // count towards --exit-code any more and is still printed below, so asking Drifted here + // would print "No differences." over it. + if (!diff.Reported) { text.AppendLine(Texts.Of("cli.diff.same")); @@ -50,41 +54,39 @@ internal static string Render(SnapshotDiff diff) return text.ToString(); } - private static void Caveats(StringBuilder text, ComparisonCaveats caveats, IReadOnlyList neitherRead) + /// + /// Every line that makes the comparison less than exact, and a blank line after them when there + /// were any. + /// + /// Collected and then written, since 2026-09-30, when three caveats and the count of + /// per-user copies joined the four there were. One condition per line of a table rather than a + /// flag set in eight branches - the shape a method pays for once it is long enough to be counted. + /// + private static void Caveats(StringBuilder text, SnapshotDiff diff) { - var said = false; - - if (caveats.ElevationDiffers) - { - text.AppendLine(Texts.Of("cli.diff.caveat.elevation")); - said = true; - } - - if (caveats.MachineDiffers) - { - text.AppendLine(Texts.Of("cli.diff.caveat.machine")); - said = true; - } - - if (caveats.OperatingSystemDiffers) - { - text.AppendLine(Texts.Of("cli.diff.caveat.operatingSystem")); - said = true; - } - - if (caveats.ToolVersionDiffers) - { - text.AppendLine(Texts.Of("cli.diff.caveat.tool")); - said = true; - } - - if (neitherRead.Count > 0) + var caveats = diff.Caveats; + + (bool Says, Func Line)[] lines = + [ + (caveats.ElevationDiffers, () => Texts.Of("cli.diff.caveat.elevation")), + (caveats.MachineDiffers, () => Texts.Of("cli.diff.caveat.machine")), + (caveats.OperatingSystemDiffers, () => Texts.Of("cli.diff.caveat.operatingSystem")), + (caveats.ToolVersionDiffers, () => Texts.Of("cli.diff.caveat.tool")), + (caveats.LanguageDiffers, () => Texts.Of("cli.diff.caveat.language")), + (caveats.AccountDiffers, () => Texts.Of("cli.diff.caveat.account")), + (caveats.NotKnown.Count > 0, () => Texts.Of("cli.diff.caveat.notKnown", string.Join(", ", caveats.NotKnown))), + (diff.NeitherRead.Count > 0, () => Texts.Of("cli.diff.caveat.neitherRead", string.Join(", ", diff.NeitherRead))), + (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(); + + foreach (var line in said) { - text.AppendLine(Texts.Of("cli.diff.caveat.neitherRead", string.Join(", ", neitherRead))); - said = true; + text.AppendLine(line); } - if (said) + if (said.Count > 0) { text.AppendLine(); } diff --git a/src/Bws.Cli/PlanJson.cs b/src/Bws.Cli/PlanJson.cs index e27bca0..ff75223 100644 --- a/src/Bws.Cli/PlanJson.cs +++ b/src/Bws.Cli/PlanJson.cs @@ -11,6 +11,14 @@ namespace Bws.Cli; /// Warnings carry their kind as well as their sentence. The sentence is for a person and /// may be reworded, the kind is for a script deciding whether to go ahead, and a script /// that had to match on English prose would break the first time the wording improved. +/// +/// EVERY ENUMERATED VALUE IS SPELLED THE WAY ITS ENUMERATION SPELLS IT, SINCE 2026-09-30 - +/// rule 5 of docs/03, stability report round 2 point 8, owner's decision, a breaking change in 0.x. +/// Until then action, operation, reason, outcome, skippedBecause and the warning kind went through +/// a helper lowering the first letter, while status and startType in the SAME document went through +/// ToString - so one result said "outcome": "succeeded" beside "status": "Running". A script had to +/// know which field was in which convention, and the only place to learn it was this file. The +/// snapshot made the same repair on 2026-08-26 and measured the direction then. /// /// /// The type a step of that kind writes, and null for every other step. @@ -18,7 +26,7 @@ namespace Bws.Cli; /// ADDED 2026-08-25, THE DAY THE COMMAND LINE LEARNED THE VERB, AND docs/02 SAID IT WOULD BE. /// The kinds of step grew a fourth on that day and it could not reach this document, because the /// only interface that could ask for one was the window. A verb here means a plan can carry one, and -/// a step reading only "setStartType" would leave out the whole of what it does. +/// a step reading only "SetStartType" would leave out the whole of what it does. /// /// Written as null rather than left out, like beside /// it. A reader that has to tell "no start type is involved" from "the field is missing" is a @@ -153,7 +161,7 @@ private static string Render(OperationPlan plan, PlanRun? run) { var shape = new PlanJsonShape { - Action = Camel(plan.Action.Kind.ToString()), + Action = plan.Action.Kind.ToString(), ServiceName = plan.Action.ServiceName, IncludeDependents = plan.Action.IncludeDependents, AlsoStop = plan.Action.AlsoStop, @@ -164,8 +172,8 @@ private static string Render(OperationPlan plan, PlanRun? run) .. plan.Steps.Select(step => new PlanStepJson( step.ServiceName, step.DisplayName, - Camel(step.Operation.ToString()), - Camel(step.Reason.ToString()), + step.Operation.ToString(), + step.Reason.ToString(), Written(step), Delayed(step))) ], @@ -173,7 +181,7 @@ private static string Render(OperationPlan plan, PlanRun? run) Warnings = [ .. plan.Warnings.Select(warning => new PlanWarningJson( - Camel(warning.Kind.ToString()), + warning.Kind.ToString(), warning.ServiceName, warning.Related, PlanText.Describe(warning))) @@ -183,10 +191,10 @@ private static string Render(OperationPlan plan, PlanRun? run) ? null : [.. run.Results.Select(result => new StepResultJson( result.Step.ServiceName, - Camel(result.Step.Operation.ToString()), - Camel(result.Step.Reason.ToString()), - Camel(result.Outcome.ToString()), - result.SkippedBecause is null ? null : Camel(result.SkippedBecause.Value.ToString()), + result.Step.Operation.ToString(), + result.Step.Reason.ToString(), + result.Outcome.ToString(), + result.SkippedBecause?.ToString(), result.Status.ToString(), // A number or nothing, the same shape the listing gives this field. Absent and @@ -205,8 +213,6 @@ private static string Render(OperationPlan plan, PlanRun? run) return JsonSerializer.Serialize(shape, Options); } - private static string Camel(string name) => char.ToLowerInvariant(name[0]) + name[1..]; - /// /// The start type a step writes, in the words the machine readable listing already uses. /// diff --git a/src/Bws.Cli/Program.cs b/src/Bws.Cli/Program.cs index 4c8409c..a066ea3 100644 --- a/src/Bws.Cli/Program.cs +++ b/src/Bws.Cli/Program.cs @@ -178,8 +178,9 @@ // Only when asked. Every other code in the table answers "did the tool work", and // this is the one place where a code can also answer "what did it find" - which is a - // different question and a script has to opt into being told that way. - exit = options.ExitCodeOnDifference && difference.Any ? ExitCode.Differences : ExitCode.Ok; + // different question and a script has to opt into being told that way. What it finds is + // DRIFT since 2026-09-30 (stability report D-2): configuration, never running state alone. + exit = options.ExitCodeOnDifference && difference.Drifted ? ExitCode.Differences : ExitCode.Ok; } else if (options.Kind == CommandKind.SnapshotCreate) { diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 168044a..90a7eab 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -10,7 +10,7 @@ "cli.unknownCommand": "There is no command {0}. There is: {1}.", "cli.unknownCommandDidYouMean": "There is no command {0}. Did you mean {1}?", - "cli.usage": "Examples:\n bws list --query \"start:auto !status:running\" what should be up and is not\n bws show Spooler everything known about one entry\n bws stop Spooler --dry-run --dependents what stopping it would take down\n bws start-type Spooler manual --dry-run what taking it off automatic would do\n bws kill Spooler --dry-run what ending its process would take with it\n bws snapshot create before.json freeze the machine before a change\n\nUsage:\n bws [-h|--help] [--version]\n bws list [--query TEXT] [--signatures] [--memory] [--required-by] [--follow-network]\n [--json] [--timing]\n bws show NAME [--full] [--follow-network] [--json] [--timing]\n bws stop|start|restart NAME [--dry-run] [--dependents] [--timeout SECONDS] [--json] [--timing]\n bws kill NAME [--force] [--restart] [--dry-run] [--dependents] [--timeout SECONDS] [--json] [--timing]\n bws start-type NAME automatic|delayed|manual|disabled [--stop] [--dry-run] [--json] [--timing]\n bws snapshot create [FILE] [--note TEXT] [--follow-network] [--force] [--json] [--timing]\n bws snapshot diff EARLIER LATER [--exit-code] [--json] [--timing]\n bws snapshot diff EARLIER --live [--exit-code] [--json] [--timing]\n bws license [--components]\n\n license says what this program is under and what it carries that somebody else\n wrote. It reads nothing at all - no service manager, no disk, no network - so it\n answers on a machine with no internet. --components turns the notice into every\n component with its version, its licence and where it came from.\n\n --signatures reads who signed each binary. Several seconds, so it is off unless asked.\n --memory reads what each running entry's process is using. Fast, and off by default\n because it is a measurement rather than a setting: it is different a second later.\n A query about either turns that one on by itself.\n --required-by reads which entries break if one is stopped. Windows is asked directly\n rather than the answer being worked out from what everything declares, so entries grouped\n by a load order name are counted too. It costs a call per entry, so it is off unless asked.\n show and snapshot create read it every time, without a switch.\n --follow-network lets the tool reach off this machine at all. Two things need that,\n and both are off unless you ask.\n One is a launch path that lives on somebody else's share. One unreachable share costs\n twenty one seconds, and the connection authenticates as whoever ran it. Without the\n switch the disk question for such an entry is reported as not read, never as missing,\n and the path itself is still shown.\n The other is checking a signature whose certificate chain this machine does not already\n hold: left to itself Windows goes and fetches the missing certificate, which is a\n connection to a third party in the middle of an ordinary listing. Without the switch\n the check uses only what is here, and any result that might have been caused by not\n looking is reported as unread rather than as a verdict about the certificate - so this\n never calls a certificate bad on the strength of not having looked it up. A file this\n machine can verify on its own reads the same either way.\n --timeout is how long the tool waits for one step to reach the state it asked for,\n counted from the moment the manager accepts the request. Sixty seconds unless you say\n otherwise. Running out of it is not a failure, it is the end of watching: the report\n says where the entry was left, and an entry left stopping usually arrives by itself.\n It is not a cap on how long the command takes. The manager answers in its own time,\n and for a service that never reports itself that answer takes tens of seconds - the\n report says so when it happens.\n\n kill is for a service that will not stop. It asks politely first and ends the process\n behind the entry only if that does not work, so an entry which stops on its own is never\n ended - the preview shows both steps and the second one says it is conditional.\n Ending a process is the one thing this tool does that nobody can refuse on the machine's\n behalf, and it takes every other service living in that process with it whether or not\n they stopped first. The preview names them, and names the process by number.\n --force skips asking politely and ends the process straight away. It changes the plan\n rather than the running of it, so the preview shows one step instead of several and the\n difference is visible before anything happens.\n --restart brings the entry back once the process is gone, along with anything that shared\n it. Without it the machine is left with those services stopped, and the report says how to\n start them again.\n Windows spells this idea --force on Stop-Service and means something else by it - there it\n means \"even if something depends on it\", which is what --dependents does here. That is why\n this is a verb of its own rather than a switch on stop.\n\n show prints everything this tool knows about one entry, including the parts a listing\n leaves out unless asked: the signature, the privileges, the security descriptor and the\n memory. It reads all of them every time, because over one entry that costs about sixty\n milliseconds where over the whole machine it costs a second.\n Fields that are genuinely empty are left out. --full prints those as well. A field\n nobody could read is printed either way, because leaving one out would look like an\n answer.\n --json gives the same document bws list --json gives for that entry, on its own rather\n than inside an array of one.\n\n start-type says what the manager will do with an entry at the next boot. Without\n --stop it moves nothing: an entry that is running keeps running, and one that is\n stopped stays stopped - the plan says so when it matters. Disabled is the one worth\n pausing over, because it stops the manager starting the entry at all - including on\n demand, for something else that needs it.\n delayed is automatic, started a little after the other automatic entries - the same\n word start:delayed asks about in a query. Windows refuses it for an entry in a load\n order group, and the plan says so before anything happens. Every one of the four\n also writes whether the entry starts late, as sc.exe does, so automatic on a delayed\n entry makes it an ordinary automatic one.\n --stop, beside disabled only, stops the entry in the same plan, after the setting is\n written. Without it a running entry set to disabled keeps running until somebody stops\n it or the machine restarts. The stop waits the usual sixty seconds, and --dependents\n does not apply: an entry something else needs is named and left for you to decide.\n\n snapshot create always reads signatures and hashes, because a snapshot is kept and\n compared later, and one without them would compare against one with them as though the\n machine had changed. Without a file name it writes into the current directory.\n A snapshot describes the whole machine and is worth filing accordingly: every launch\n path and file hash, the account each entry runs as, its privileges and its security\n descriptor, and the name of this machine and of the account that took it. It is written\n with whatever permissions its directory already gives it, and this tool narrows nothing -\n so a directory other people can read is one they can read all of that in.\n --note says what the snapshot was taken for.\n --force here writes over a file that is already there. Without it, an existing file is left\n alone and the command ends without writing - a snapshot is kept for months, and this\n will not replace one, or anything else, unless told to.\n\n snapshot diff says what changed going from the first file to the second. Configuration\n differences and running state are reported apart, because two snapshots taken a day\n apart differ in what was running and almost none of it is drift.\n --exit-code ends with code 5 when anything differs. Off by default, so a script that\n only wants the differences printed is not tripped by finding some.\n --live compares the file against this machine as it is now. It reads signatures and\n hashes, like snapshot create, because the file it is compared against has them.\n\n Every command, switch and exit code, laid out to be read: https://betterwindowsservices.donislawdev.com/cli-reference/", + "cli.usage": "Examples:\n bws list --query \"start:auto !status:running\" what should be up and is not\n bws show Spooler everything known about one entry\n bws stop Spooler --dry-run --dependents what stopping it would take down\n bws start-type Spooler manual --dry-run what taking it off automatic would do\n bws kill Spooler --dry-run what ending its process would take with it\n bws snapshot create before.json freeze the machine before a change\n\nUsage:\n bws [-h|--help] [--version]\n bws list [--query TEXT] [--signatures] [--memory] [--required-by] [--follow-network]\n [--json] [--timing]\n bws show NAME [--full] [--follow-network] [--json] [--timing]\n bws stop|start|restart NAME [--dry-run] [--dependents] [--timeout SECONDS] [--json] [--timing]\n bws kill NAME [--force] [--restart] [--dry-run] [--dependents] [--timeout SECONDS] [--json] [--timing]\n bws start-type NAME automatic|delayed|manual|disabled [--stop] [--dry-run] [--json] [--timing]\n bws snapshot create [FILE] [--note TEXT] [--follow-network] [--force] [--json] [--timing]\n bws snapshot diff EARLIER LATER [--exit-code] [--json] [--timing]\n bws snapshot diff EARLIER --live [--exit-code] [--json] [--timing]\n bws license [--components]\n\n license says what this program is under and what it carries that somebody else\n wrote. It reads nothing at all - no service manager, no disk, no network - so it\n answers on a machine with no internet. --components turns the notice into every\n component with its version, its licence and where it came from.\n\n --signatures reads who signed each binary. Several seconds, so it is off unless asked.\n --memory reads what each running entry's process is using. Fast, and off by default\n because it is a measurement rather than a setting: it is different a second later.\n A query about either turns that one on by itself.\n --required-by reads which entries break if one is stopped. Windows is asked directly\n rather than the answer being worked out from what everything declares, so entries grouped\n by a load order name are counted too. It costs a call per entry, so it is off unless asked.\n show and snapshot create read it every time, without a switch.\n --follow-network lets the tool reach off this machine at all. Two things need that,\n and both are off unless you ask.\n One is a launch path that lives on somebody else's share. One unreachable share costs\n twenty one seconds, and the connection authenticates as whoever ran it. Without the\n switch the disk question for such an entry is reported as not read, never as missing,\n and the path itself is still shown.\n The other is checking a signature whose certificate chain this machine does not already\n hold: left to itself Windows goes and fetches the missing certificate, which is a\n connection to a third party in the middle of an ordinary listing. Without the switch\n the check uses only what is here, and any result that might have been caused by not\n looking is reported as unread rather than as a verdict about the certificate - so this\n never calls a certificate bad on the strength of not having looked it up. A file this\n machine can verify on its own reads the same either way.\n --timeout is how long the tool waits for one step to reach the state it asked for,\n counted from the moment the manager accepts the request. Sixty seconds unless you say\n otherwise. Running out of it is not a failure, it is the end of watching: the report\n says where the entry was left, and an entry left stopping usually arrives by itself.\n It is not a cap on how long the command takes. The manager answers in its own time,\n and for a service that never reports itself that answer takes tens of seconds - the\n report says so when it happens.\n\n kill is for a service that will not stop. It asks politely first and ends the process\n behind the entry only if that does not work, so an entry which stops on its own is never\n ended - the preview shows both steps and the second one says it is conditional.\n Ending a process is the one thing this tool does that nobody can refuse on the machine's\n behalf, and it takes every other service living in that process with it whether or not\n they stopped first. The preview names them, and names the process by number.\n --force skips asking politely and ends the process straight away. It changes the plan\n rather than the running of it, so the preview shows one step instead of several and the\n difference is visible before anything happens.\n --restart brings the entry back once the process is gone, along with anything that shared\n it. Without it the machine is left with those services stopped, and the report says how to\n start them again.\n Windows spells this idea --force on Stop-Service and means something else by it - there it\n means \"even if something depends on it\", which is what --dependents does here. That is why\n this is a verb of its own rather than a switch on stop.\n\n show prints everything this tool knows about one entry, including the parts a listing\n leaves out unless asked: the signature, the privileges, the security descriptor and the\n memory. It reads all of them every time, because over one entry that costs about sixty\n milliseconds where over the whole machine it costs a second.\n Fields that are genuinely empty are left out. --full prints those as well. A field\n nobody could read is printed either way, because leaving one out would look like an\n answer.\n --json gives the same document bws list --json gives for that entry, on its own rather\n than inside an array of one.\n\n start-type says what the manager will do with an entry at the next boot. Without\n --stop it moves nothing: an entry that is running keeps running, and one that is\n stopped stays stopped - the plan says so when it matters. Disabled is the one worth\n pausing over, because it stops the manager starting the entry at all - including on\n demand, for something else that needs it.\n delayed is automatic, started a little after the other automatic entries - the same\n word start:delayed asks about in a query. Windows refuses it for an entry in a load\n order group, and the plan says so before anything happens. Every one of the four\n also writes whether the entry starts late, as sc.exe does, so automatic on a delayed\n entry makes it an ordinary automatic one.\n --stop, beside disabled only, stops the entry in the same plan, after the setting is\n written. Without it a running entry set to disabled keeps running until somebody stops\n it or the machine restarts. The stop waits the usual sixty seconds, and --dependents\n does not apply: an entry something else needs is named and left for you to decide.\n\n snapshot create always reads signatures and hashes, because a snapshot is kept and\n compared later, and one without them would compare against one with them as though the\n machine had changed. Without a file name it writes into the current directory.\n A snapshot describes the whole machine and is worth filing accordingly: every launch\n path and file hash, the account each entry runs as, its privileges and its security\n descriptor, and the name of this machine and of the account that took it. It is written\n with whatever permissions its directory already gives it, and this tool narrows nothing -\n so a directory other people can read is one they can read all of that in.\n --note says what the snapshot was taken for.\n --force here writes over a file that is already there. Without it, an existing file is left\n alone and the command ends without writing - a snapshot is kept for months, and this\n will not replace one, or anything else, unless told to.\n\n snapshot diff says what changed going from the first file to the second. Configuration\n differences and running state are reported apart, because two snapshots taken a day\n apart differ in what was running and almost none of it is drift.\n --exit-code ends with code 5 when the configuration differs: an entry added or removed,\n or one set up differently. What was running at the two moments is reported and does not\n count, and neither do per-user session copies, which are left out and counted. Off by\n default, so a script that only wants the differences printed is not tripped by them.\n --live compares the file against this machine as it is now. It reads signatures and\n hashes, like snapshot create, because the file it is compared against has them.\n\n Every command, switch and exit code, laid out to be read: https://betterwindowsservices.donislawdev.com/cli-reference/", "cli.unknownOption": "Unknown option: {0}", "cli.wordsNotTaken": "{0} takes {1}. Nothing here can use: {2}.", "cli.takes.oneName": "one name", @@ -32,6 +32,7 @@ "cli.licence.more": "Run `bws license --components` for every one of them with its version and licence.", "cli.snapshot.fileExists": "There is already a file at {0}. A snapshot is kept and compared later, so this will not replace one without being told to. Pick another name, or add --force.", "cli.snapshot.quarantined": "What was at {0} could not be read as a snapshot, so it was kept rather than replaced. It is now at {1}.", + "cli.snapshot.isFolder": "{0} is a folder, and a snapshot is written as one file. Name a file inside it instead.", "cli.snapshot.cannotQuarantine": "What was at {0} could not be read as a snapshot and could not be moved aside either, so nothing was written. {1}", "cli.optionNeedsValue": "{0} needs a value after it.", "cli.optionNotForCommand": "{0} does not apply to {1}. It works with: {2}.", @@ -262,8 +263,12 @@ "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.", "cli.diff.caveat.machine": "The two snapshots come from different machines.", - "cli.diff.caveat.operatingSystem": "The two snapshots come from different versions of Windows.", + "cli.diff.caveat.operatingSystem": "The two snapshots come from different versions of Windows, counting the monthly update. A difference in a service that came with Windows may be the update rather than somebody's change.", "cli.diff.caveat.tool": "The two snapshots were written by different versions of this tool.", + "cli.diff.caveat.language": "The service manager names things in a different language on each side, so display names and descriptions were not compared.", + "cli.diff.caveat.account": "The two snapshots were taken by different accounts. What one account may read, the other may be refused.", + "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.info.timingCompared": "Read and compared two snapshots in {0} ms.", diff --git a/src/Bws.Cli/SnapshotFiles.cs b/src/Bws.Cli/SnapshotFiles.cs index e1e8fd7..18ea309 100644 --- a/src/Bws.Cli/SnapshotFiles.cs +++ b/src/Bws.Cli/SnapshotFiles.cs @@ -97,6 +97,19 @@ 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; diff --git a/src/Bws.Core/NativeMethods.txt b/src/Bws.Core/NativeMethods.txt index bce1023..391c4b3 100644 --- a/src/Bws.Core/NativeMethods.txt +++ b/src/Bws.Core/NativeMethods.txt @@ -225,6 +225,15 @@ SERVICE_DESCRIPTIONW SERVICE_FAILURE_ACTIONSW SC_ACTION SC_ACTION_TYPE + +// The language the service manager names things in, for the snapshot's metadata since +// 2026-09-30 (stability report D-5, package E). NOT the caller's language: measured that day +// with tools/scm-probe/language-probe.ps1, the manager handed back the same Polish display +// names and descriptions whether the calling thread asked for en-US or pl-PL, while the +// system's own error text changed with it. So the fact worth recording is the system's +// preferred UI language, and this is the one call that answers it. +GetSystemPreferredUILanguages +MUI_LANGUAGE_NAME SERVICE_TRIGGER_INFO SERVICE_TRIGGER WIN32_ERROR diff --git a/src/Bws.Core/Snapshots/ComparisonCaveats.cs b/src/Bws.Core/Snapshots/ComparisonCaveats.cs new file mode 100644 index 0000000..48386d3 --- /dev/null +++ b/src/Bws.Core/Snapshots/ComparisonCaveats.cs @@ -0,0 +1,87 @@ +namespace Bws.Core.Snapshots; + +/// +/// What makes this comparison less than exact, as facts rather than sentences. +/// +/// Facts because the core does not write anything a person reads - `ADR-3` keeps it from +/// knowing an interface exists, and rule 13 keeps user-facing wording out of code entirely. +/// Whoever displays this turns the flags into words in their own language. +/// +/// Its own file since 2026-09-30, when it grew from four facts to seven - stability report +/// D-5, package E. The rule that works them out moved here with it, so that the method matching +/// the two sides' entries does not also decide what the two sides' metadata means. +/// +/// +/// The two were taken on different Windows, counting the monthly update when both files say which +/// one. Wider since 2026-09-30: until then it compared only the text that ends in ".0" where +/// the update belongs, so a machine either side of an update compared as the same Windows. +/// +/// +/// The two managers name things in different languages - both files say which, and they disagree. +/// Display names and descriptions are then not compared on any entry, because every translated one +/// would differ without anybody having changed anything. +/// +/// +/// Different accounts took the two. What one account may read another may be refused, and the +/// language decision of 2026-09-30 leans on this too: whether the manager follows the caller's +/// own display language was NOT measured, and two accounts on one machine are where it would show. +/// Compared without case, because Windows compares account names that way. +/// +/// +/// Metadata fields at least one of the two files does not carry, so the caveat each one feeds could +/// not be decided either way - camelCase names, as they are written in the file. A version four +/// file carries neither of the two added in version five, and saying so is the difference between +/// "the same" and "nobody could tell". +/// +public sealed record ComparisonCaveats( + bool ElevationDiffers, + bool MachineDiffers, + bool OperatingSystemDiffers, + bool ToolVersionDiffers, + bool LanguageDiffers, + bool AccountDiffers, + IReadOnlyList NotKnown) +{ + /// The field name of in the file. + internal const string VersionField = "operatingSystemVersion"; + + /// The field name of in the file. + internal const string LanguageField = "namesLanguage"; + + /// + /// The caveats two snapshots' metadata add up to. + /// + /// Two fields decided three ways, and "not known" is the one that is easy to lose. When + /// both sides carry a value the answer is same or different. When either side does not, the + /// answer is neither - it goes on and the flag stays false, so nothing + /// that could not be checked is reported as a difference and nothing is reported as checked. + /// + internal static ComparisonCaveats Between(SnapshotMetadata before, SnapshotMetadata after) + { + var notKnown = new List(); + + if (before.OperatingSystemVersion is null || after.OperatingSystemVersion is null) + { + notKnown.Add(VersionField); + } + + if (before.NamesLanguage is null || after.NamesLanguage is null) + { + notKnown.Add(LanguageField); + } + + return new ComparisonCaveats( + before.Elevated != after.Elevated, + !string.Equals(before.Machine, after.Machine, StringComparison.OrdinalIgnoreCase), + !string.Equals(before.OperatingSystem, after.OperatingSystem, StringComparison.Ordinal) + || Disagree(before.OperatingSystemVersion, after.OperatingSystemVersion, StringComparison.Ordinal), + !string.Equals(before.Tool, after.Tool, StringComparison.Ordinal), + Disagree(before.NamesLanguage, after.NamesLanguage, StringComparison.OrdinalIgnoreCase), + !string.Equals(before.TakenBy, after.TakenBy, StringComparison.OrdinalIgnoreCase), + notKnown); + } + + /// Both known and different. Either one missing is not a disagreement - it is not knowing. + private static bool Disagree(string? before, string? after, StringComparison comparison) => + before is not null && after is not null && !string.Equals(before, after, comparison); +} diff --git a/src/Bws.Core/Snapshots/InstancesLeftOut.cs b/src/Bws.Core/Snapshots/InstancesLeftOut.cs new file mode 100644 index 0000000..d24e2bd --- /dev/null +++ b/src/Bws.Core/Snapshots/InstancesLeftOut.cs @@ -0,0 +1,16 @@ +namespace Bws.Core.Snapshots; + +/// +/// How many per-user session copies each side held and the comparison left out. +/// +/// Counted rather than dropped, since 2026-09-30 - stability report D-1, owner's decision. A +/// copy belongs to one signed-in session and comes and goes with it, so comparing them reported +/// drift nobody made - the glossary calls it a session copy. Leaving them out without a number +/// would be the silence rule 8 forbids: somebody reading "no differences" is owed how much was not +/// looked at. +/// +public sealed record InstancesLeftOut(int Earlier, int Later) +{ + /// Whether there is anything to say at all. + public bool Any => Earlier > 0 || Later > 0; +} diff --git a/src/Bws.Core/Snapshots/Snapshot.cs b/src/Bws.Core/Snapshots/Snapshot.cs index 440969e..5380b69 100644 --- a/src/Bws.Core/Snapshots/Snapshot.cs +++ b/src/Bws.Core/Snapshots/Snapshot.cs @@ -62,6 +62,35 @@ public sealed record SnapshotMetadata /// Which build wrote it, so that a difference in output can be traced to a change in us. public required string Tool { get; init; } + /// + /// Windows down to the monthly update, as in "10.0.26200.9550". Null when it is not known. + /// + /// Beside rather than instead of it, schema five, 2026-09-30. + /// That field ends in ".0" where the update belongs, so two snapshots either side of a monthly + /// update read as taken on the same Windows - stability report D-5. Changing what the old field + /// says would be the quiet kind of break its row in docs/02 warns about, so the full number is + /// a field of its own. + /// + /// Not required, and that is what lets a version four file still be read. Such a file + /// has no such field, and it arrives as null - "not known", which a comparison says rather than + /// treating as "the same". gives null as well + /// when the machine will not say, for the same reason. + /// + public string? OperatingSystemVersion { get; init; } + + /// + /// The language the service manager names things in, as in "pl-PL". Null when it is not known. + /// + /// The system's language, not the session's - measured, and then the owner's decision of + /// 2026-09-30. carries the measurement. Two snapshots + /// whose managers name things in different languages differ on every translated display name and + /// description without anybody having changed anything, so a comparison of two such files leaves + /// those two fields out and says so. + /// + /// Not required, for the reason the field above gives. + /// + public string? NamesLanguage { get; init; } + /// /// Everything about the machine and the session, filled in from the machine itself. /// @@ -93,7 +122,9 @@ public static SnapshotMetadata Of(string? note, IClock clock) // of "am I elevated" would be a second reader of one fact. Elevated = Session.IsElevated(), Note = string.IsNullOrWhiteSpace(note) ? null : note.Trim(), - Tool = CoreAssembly.Version + Tool = CoreAssembly.Version, + OperatingSystemVersion = SystemFacts.OperatingSystemVersion(), + NamesLanguage = SystemFacts.NamesLanguage() }; } @@ -178,8 +209,28 @@ public sealed record Snapshot(SnapshotMetadata Metadata, IReadOnlyListFOUR TO FIVE ON 2026-09-30, AND THIS IS THE FIRST BUMP THAT KEEPS READING THE VERSION + /// BEFORE IT - stability report D-5, owner's decision. Two fields joined the METADATA, not the + /// entries: the Windows version down to the monthly update, and the language the manager names + /// things in. Neither is required, so a version four file deserialises with both as null, and + /// null is an honest "not known" there - nothing in an entry changed, so no comparison of a four + /// against a five can invent a difference out of the format. + /// says how far back this build reads. + /// + /// The number moves anyway, and for the reader going the other way. A build of 0.3.0 reads + /// only four, and the serialiser skips members it does not know - so without the bump an older + /// build would read a five as a four, drop both fields without a word and compare as though + /// nothing were missing. With it that build refuses the file by name. + /// + public const int CurrentSchemaVersion = 5; + + /// + /// The oldest schema this build still reads. Four since 2026-09-30 - see the version five + /// paragraph above for why that one bump, unlike every earlier one, could keep reading its + /// predecessor. /// - public const int CurrentSchemaVersion = 4; + public const int OldestSchemaVersionRead = 4; /// /// Freezes a listing. @@ -225,10 +276,11 @@ internal bool MissingParts(out string? failure) /// /// Whether the entries are something a comparison can be run against. /// - /// Three questions, and all three are asked of the document rather than trusted from the + /// Four questions, and all four are asked of the document rather than trusted from the /// type. A required property does not reach the elements of a list, it does not reach a /// property spelled out with null after it, and nothing anywhere says a document holds each - /// service once. + /// service once. The fourth - a null among the names of fields nobody read - joined on + /// 2026-09-30, and it is the first question about the inside of an entry rather than its identity. /// /// Names are compared without case, and that is a decision about what a snapshot is /// rather than a detail of any one caller - owner's decision, 2026-08-03. Windows cannot hold @@ -275,6 +327,19 @@ internal bool BrokenEntries(out string? failure) return true; } + // THE ELEMENTS OF ONE LIST, since 2026-09-30 - stability report D-3, measured: a copy of a + // real snapshot with `"notRead": [null]` in one entry ended `bws snapshot diff` with + // "Object reference not set to an instance of an object." and code 1. The comparison + // turns each name on this list into the field it speaks about, and a null name is not + // one. It is the only list whose elements are used that way - a null inside dependsOn + // or triggers is compared as "nothing", which is an answer. + if (entry.NotRead?.Any(field => field is null) == true) + { + failure = $"Entry {index + 1} ('{entry.ServiceName}') has an empty name in its list of fields nobody read."; + + return true; + } + if (!seen.Add(entry.ServiceName)) { failure = $"More than one entry is called '{entry.ServiceName}'."; diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.cs b/src/Bws.Core/Snapshots/SnapshotDiff.cs index dcd7a3a..c50ea2f 100644 --- a/src/Bws.Core/Snapshots/SnapshotDiff.cs +++ b/src/Bws.Core/Snapshots/SnapshotDiff.cs @@ -38,19 +38,6 @@ public sealed record ChangedEntry( IReadOnlyList Differences, IReadOnlyList Incomparable); -/// -/// What makes this comparison less than exact, as facts rather than sentences. -/// -/// Facts because the core does not write anything a person reads - `ADR-3` keeps it from -/// knowing an interface exists, and rule 13 keeps user-facing wording out of code entirely. -/// Whoever displays this turns the flags into words in their own language. -/// -public sealed record ComparisonCaveats( - bool ElevationDiffers, - bool MachineDiffers, - bool OperatingSystemDiffers, - bool ToolVersionDiffers); - /// /// What changed between two snapshots. /// @@ -78,7 +65,7 @@ public sealed record ComparisonCaveats( /// this against a real pair rather than from reading it. An elevated snapshot compared with /// a restricted one put five entries under "changed" that had nothing changed about them - /// only a security descriptor one side was refused. The summary then read "5 changed, 0 -/// differences", and was true, so --exit-code would have failed a pipeline +/// differences", and what is now was true, so --exit-code would have failed a pipeline /// over a comparison that found nothing. That is the same false alarm the whole handling of /// missing entries exists to prevent, arriving through the exit code instead. /// @@ -97,6 +84,10 @@ public sealed record ComparisonCaveats( /// 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. /// +/// +/// 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( IReadOnlyList Added, IReadOnlyList Removed, @@ -104,7 +95,8 @@ public sealed record SnapshotDiff( IReadOnlyList NotFullyCompared, IReadOnlyList NeitherRead, IReadOnlyList Uncertain, - ComparisonCaveats Caveats) + ComparisonCaveats Caveats, + InstancesLeftOut LeftOut) { /// Identity, not a value - it is how the two sides are matched at all. private const string Identity = "serviceName"; @@ -127,13 +119,41 @@ public sealed record SnapshotDiff( private const string State = "status"; /// - /// Whether anything actually differs. + /// The two fields a person reads in their own language, left out of every entry when the two + /// managers name things in different languages (). + /// + private static readonly string[] Translated = ["description", "displayName"]; + + /// + /// Whether the machine drifted: an entry added or removed, or one set up differently. + /// + /// CONFIGURATION ONLY SINCE 2026-09-30 - stability report D-2, owner's decision, a change of + /// meaning under an unchanged exit code. Until then this was Any, and an entry that + /// differed only in what it was doing at the two moments counted - so a nightly + /// --exit-code paged somebody over a service that had stopped by itself, which contradicts + /// the decision of 2026-08-01 this file opens with: running state is not drift. It is still + /// reported, under , and it no longer decides anything. /// /// Deliberately not counting what could not be compared, and not counting the entries /// only one side could see. Both of those are admissions about the comparison rather /// than findings about the machine, and this is the answer --exit-code gives a pipeline. + /// + /// Its readers had a second question hidden in them, found before the change rather than + /// after it. The text report asked this to decide whether to say "no differences", and + /// with the new meaning that sentence would have hidden an entry that differed only in state. + /// It asks now. + /// + public bool Drifted => + Added.Count > 0 + || Removed.Count > 0 + || Changed.Any(entry => entry.Differences.Any(difference => difference.Group == DifferenceGroup.Configuration)); + + /// + /// Whether there is anything at all to put in front of a person - a difference of either kind, + /// something one side could not see, or something one side never read. /// - public bool Any => Added.Count > 0 || Removed.Count > 0 || Changed.Count > 0; + public bool Reported => + Added.Count > 0 || Removed.Count > 0 || Changed.Count > 0 || Uncertain.Count > 0 || NotFullyCompared.Count > 0; /// /// Compares two snapshots, or says why one of them is not something to compare. @@ -204,13 +224,20 @@ private static bool Unusable(Snapshot snapshot, string side, out string? failure private static SnapshotDiff Between(Snapshot before, Snapshot after) { + var caveats = ComparisonCaveats.Between(before.Metadata, after.Metadata); + var elevationDiffers = caveats.ElevationDiffers; + + // Not looked at on any entry, rather than named against each - the caveat says it once. + var ignored = caveats.LanguageDiffers ? Translated : []; + + var earlier = Kept(before.Entries, out var leftOutEarlier); + var later = Kept(after.Entries, out var leftOutLater); + // Case-insensitively, because that is how Windows treats a service name, so two // spellings are the same service rather than two. What used to stand here was a note // saying nothing checked for two entries matching this way - the caller above now does. - var left = before.Entries.ToDictionary(entry => entry.ServiceName, StringComparer.OrdinalIgnoreCase); - var right = after.Entries.ToDictionary(entry => entry.ServiceName, StringComparer.OrdinalIgnoreCase); - - var elevationDiffers = before.Metadata.Elevated != after.Metadata.Elevated; + var left = earlier.ToDictionary(entry => entry.ServiceName, StringComparer.OrdinalIgnoreCase); + var right = later.ToDictionary(entry => entry.ServiceName, StringComparer.OrdinalIgnoreCase); var added = new List(); var removed = new List(); @@ -219,11 +246,11 @@ private static SnapshotDiff Between(Snapshot before, Snapshot after) var partial = new List(); var neitherRead = new HashSet(StringComparer.Ordinal); - foreach (var entry in Ordered(after.Entries)) + foreach (var entry in Ordered(later)) { if (left.TryGetValue(entry.ServiceName, out var was)) { - var difference = Compare(was, entry, neitherRead); + var difference = Compare(was, entry, neitherRead, ignored); if (difference is not null) { @@ -243,7 +270,7 @@ private static SnapshotDiff Between(Snapshot before, Snapshot after) } } - foreach (var entry in Ordered(before.Entries).Where(entry => !right.ContainsKey(entry.ServiceName))) + foreach (var entry in Ordered(earlier).Where(entry => !right.ContainsKey(entry.ServiceName))) { if (Invisible(elevationDiffers, blind: after.Metadata.Elevated)) { @@ -262,11 +289,30 @@ private static SnapshotDiff Between(Snapshot before, Snapshot after) partial, [.. neitherRead.OrderBy(field => field, StringComparer.Ordinal)], [.. uncertain.OrderBy(entry => entry.ServiceName, StringComparer.Ordinal)], - new ComparisonCaveats( - elevationDiffers, - !string.Equals(before.Metadata.Machine, after.Metadata.Machine, StringComparison.OrdinalIgnoreCase), - !string.Equals(before.Metadata.OperatingSystem, after.Metadata.OperatingSystem, StringComparison.Ordinal), - !string.Equals(before.Metadata.Tool, after.Metadata.Tool, StringComparison.Ordinal))); + caveats, + new InstancesLeftOut(leftOutEarlier, leftOutLater)); + } + + /// + /// The entries a comparison looks at: everything but the per-user session copies, and how many + /// of those there were. + /// + /// By the role, never by the name - stability report D-1, owner's decision of 2026-09-30. + /// A copy's name carries a session suffix, and a rule reading that shape would take a real + /// service whose name happens to end the same way for one. The role comes from the type bits the + /// manager hands over with every entry (docs/03, "Rola per-uzytkownik"), and it is spelled the + /// way the enumeration spells it, like every value in the file. The template the copies are made + /// from is not a copy and is compared field by field as before. + /// + private static List Kept(IReadOnlyList entries, out int leftOut) + { + var kept = entries + .Where(entry => !string.Equals(entry.PerUserRole, nameof(PerUserRole.Instance), StringComparison.Ordinal)) + .ToList(); + + leftOut = entries.Count - kept.Count; + + return kept; } /// @@ -279,7 +325,12 @@ [.. uncertain.OrderBy(entry => entry.ServiceName, StringComparer.Ordinal)], /// private static bool Invisible(bool elevationDiffers, bool blind) => elevationDiffers && !blind; - private static ChangedEntry? Compare(EntryDocument before, EntryDocument after, HashSet neitherRead) + /// + /// Fields not looked at on this entry at all, because a caveat about the whole comparison already + /// 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) { var was = SnapshotJson.Document(before); var now = SnapshotJson.Document(after); @@ -312,7 +363,7 @@ [.. uncertain.OrderBy(entry => entry.ServiceName, StringComparer.Ordinal)], foreach (var field in was.Select(property => property.Key) .Union(now.Select(property => property.Key), StringComparer.Ordinal) - .Where(Comparable) + .Where(field => Comparable(field) && !ignored.Contains(field, StringComparer.Ordinal)) .OrderBy(field => field, StringComparer.Ordinal)) { if (skip.Contains(field)) diff --git a/src/Bws.Core/Snapshots/SnapshotJson.cs b/src/Bws.Core/Snapshots/SnapshotJson.cs index 84416e1..2127c4b 100644 --- a/src/Bws.Core/Snapshots/SnapshotJson.cs +++ b/src/Bws.Core/Snapshots/SnapshotJson.cs @@ -171,18 +171,22 @@ public static bool TryRead(string content, out Snapshot? snapshot, out string? f return false; } - if (snapshot.Metadata.SchemaVersion != Snapshot.CurrentSchemaVersion) + if (snapshot.Metadata.SchemaVersion is < Snapshot.OldestSchemaVersionRead or > Snapshot.CurrentSchemaVersion) { // Said out loud rather than attempted. A file from a schema this build does not // know may be missing fields, or may mean something different by one it has - and // reading it anyway would produce a comparison that looks ordinary and is not. // + // A RANGE SINCE 2026-09-30, where it used to be one number. Version five added two + // fields to the metadata and none to the entries, so a version four file is read with + // both as "not known" - Snapshot.CurrentSchemaVersion carries the argument. + // // AHEAD OF THE CONTENT CHECK BELOW, and the order carries an argument: a rule about // what the entries may hold is a rule of THIS schema, so applying it to a document // written against another one would report a fault that may not be one there. failure = $"The snapshot uses schema version {snapshot.Metadata.SchemaVersion} and this " + - $"build reads version {Snapshot.CurrentSchemaVersion}."; + $"build reads versions {Snapshot.OldestSchemaVersionRead} to {Snapshot.CurrentSchemaVersion}."; snapshot = null; return false; diff --git a/src/Bws.Core/Snapshots/SystemFacts.cs b/src/Bws.Core/Snapshots/SystemFacts.cs new file mode 100644 index 0000000..cff7755 --- /dev/null +++ b/src/Bws.Core/Snapshots/SystemFacts.cs @@ -0,0 +1,111 @@ +using System.Globalization; +using System.Security; +using Microsoft.Win32; +using Windows.Win32; + +namespace Bws.Core.Snapshots; + +/// +/// Two facts about the machine a snapshot needs and the rest of the product does not: which update +/// of Windows it runs, and which language the service manager names things in. +/// +/// Both since schema five, 2026-09-30 - stability report D-5, package E, owner's decisions of +/// that day. Without the first, two snapshots either side of a monthly update compared as +/// taken on the same Windows, because Environment.OSVersion ends in ".0" where the update +/// number belongs - measured that day as "Microsoft Windows NT 10.0.26200.0" on a machine whose +/// update number is 9550. Without the second, two machines naming services in different languages +/// compared every translated display name as drift. +/// +/// Null is "not known", never a guess. Either fact can be refused, and a snapshot that wrote +/// ".0" for an update it could not read, or the install language for a system language it could +/// not ask, would be carrying something that looks like a reading and is not one - rule 8. A +/// comparison meeting null says it could not check, which is a different sentence from "the same". +/// +internal static class SystemFacts +{ + /// + /// Major, minor, build and update, as in "10.0.26200.9550", or null when the update is not known. + /// + /// The update number comes from the registry, owner's decision of 2026-09-30, and only after + /// looking for anything else. Microsoft documents that value as the place the revision is + /// kept ("OEM deployment of Windows desktop editions", and the Intune page on requirement rules + /// reads the same value). The one other road, the WinRT AnalyticsInfo version, would bring + /// the Windows SDK projection in as a new dependency and its page does not say it carries the + /// update at all. The rule against reading the registry on a short cut is about services, where + /// the manager has an API - this is not a fact about a service, and nothing else answers it. + /// + /// The first three parts come from the runtime rather than from the registry beside them. + /// Environment.OSVersion is what this format has always written as "operatingSystem", so + /// the two fields cannot disagree about the build. + /// + internal static string? OperatingSystemVersion() + { + var update = UpdateNumber(); + + if (update is null) + { + return null; + } + + var version = Environment.OSVersion.Version; + + return string.Create( + CultureInfo.InvariantCulture, + $"{version.Major}.{version.Minor}.{version.Build}.{update.Value}"); + } + + /// + /// The first of the system's preferred UI languages, as in "pl-PL", or null when it cannot be asked. + /// + /// The system's rather than this session's, and that was a measurement before it was a + /// decision. tools/scm-probe/language-probe.ps1 switched the calling thread between en-US + /// and pl-PL on a machine holding both: the system's own error text followed the switch, and the + /// manager handed back the same Polish names and descriptions under both. What it could NOT tell + /// apart is the manager following the caller's USER language against following the system's - + /// both are pl-PL there. Two different accounts on one machine are named by a caveat of their + /// own (), which is where that case lands. + /// + internal static unsafe string? NamesLanguage() + { + uint count; + uint size = 0; + + // Asked for the size first rather than guessing one, so a machine with many languages is + // never cut short into a first name that is only half of itself. + if (!PInvoke.GetSystemPreferredUILanguages(PInvoke.MUI_LANGUAGE_NAME, &count, default, &size) || size == 0) + { + return null; + } + + var buffer = new char[size]; + + fixed (char* start = buffer) + { + if (!PInvoke.GetSystemPreferredUILanguages(PInvoke.MUI_LANGUAGE_NAME, &count, start, &size)) + { + return null; + } + } + + // A list of names each ended by a null, the whole ended by a second one. The first is the + // language the system uses when nothing else is asked for. + var end = Array.IndexOf(buffer, '\0'); + var first = end < 0 ? new string(buffer) : new string(buffer, 0, end); + + return first.Length == 0 ? null : first; + } + + private static int? UpdateNumber() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + + return key?.GetValue("UBR") is int update ? update : null; + } + catch (Exception refused) when (refused is SecurityException or UnauthorizedAccessException or IOException) + { + return null; + } + } +} diff --git a/tests/Bws.Architecture.Tests/OutboundRegisters.cs b/tests/Bws.Architecture.Tests/OutboundRegisters.cs index 5af78e0..75d16b3 100644 --- a/tests/Bws.Architecture.Tests/OutboundRegisters.cs +++ b/tests/Bws.Architecture.Tests/OutboundRegisters.cs @@ -34,7 +34,9 @@ internal static class OutboundRegisters ["KERNEL32.dll"] = "Process handles and what can be asked of them without opening a process for " + "reading: OpenProcess, GetProcessTimes, TerminateProcess, and the handle types " + - "underneath all of the above. The window binds it too since 2026-09-23, for " + + "underneath all of the above. Since 2026-09-30 also GetSystemPreferredUILanguages, " + + "which a snapshot asks once for the language the service manager names things in - " + + "a local answer about this machine, nothing leaves it. The window binds it too since 2026-09-23, for " + "CloseHandle alone, which the generator declares as the release function of a " + "handle type the shell interfaces name - declared and never called.", diff --git a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs index 8996eca..e9ffb5a 100644 --- a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs +++ b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs @@ -148,6 +148,11 @@ public void No_shape_that_belongs_to_a_person_is_written_into_a_published_file() ["tests/Bws.Integration.Tests/SnapshotContractTests.cs"] = "an accented word written to a file on purpose, to prove the encoding survives", + ["tests/Bws.Core.Tests/Golden/snapshot-schema-5.json"] = + "the kept copy of the snapshot file since schema five, 2026-09-30 - the same specimens as " + + "the copy below and two more lines of metadata, so the same reason: display names written " + + "as themselves, not escaped", + ["tests/Bws.Core.Tests/Golden/snapshot-schema-4.json"] = "the kept copy of the snapshot file, holding the captured display names of the specimen " + "catalogue exactly as the format writes them - as themselves, not escaped, which is the " diff --git a/tests/Bws.Architecture.Tests/SilentCatchGuards.cs b/tests/Bws.Architecture.Tests/SilentCatchGuards.cs index bc70764..4a6f387 100644 --- a/tests/Bws.Architecture.Tests/SilentCatchGuards.cs +++ b/tests/Bws.Architecture.Tests/SilentCatchGuards.cs @@ -44,6 +44,13 @@ public sealed class SilentCatchGuards ["src/Bws.Core/Querying/QueryPatterns.cs"] = 1, ["src/Bws.Core/Querying/QueryValues.cs"] = 1, ["src/Bws.Core/Snapshots/AtomicFile.cs"] = 2, + + // 2026-09-30, stability report D-5, owner's decision that day: the monthly update number + // refused or unreadable becomes null in the snapshot's metadata - "not known" - and every + // comparison against that file says so in a line of its own. The failure reaches the person, + // the reason behind it does not. The key is readable by every account (an assessment, not a + // measurement), so the branch is expected to stay unreached. + ["src/Bws.Core/Snapshots/SystemFacts.cs"] = 1, ["src/Bws.Core/WindowsBinaryInspector.Publisher.cs"] = 1, ["src/Bws.Gui/Elevation.cs"] = 1, ["src/Bws.Gui/ListColumns.cs"] = 3, diff --git a/tests/Bws.Cli.Tests/DiffOutputTests.cs b/tests/Bws.Cli.Tests/DiffOutputTests.cs new file mode 100644 index 0000000..3ac144a --- /dev/null +++ b/tests/Bws.Cli.Tests/DiffOutputTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using Bws.Core.Snapshots; + +namespace Bws.Cli.Tests; + +/// +/// What the comparison says, as text and as a document, for what package E of the stability report +/// added on 2026-09-30: drift meaning configuration only (D-2), per-user session copies left out and +/// counted (D-1), and three caveats about the two files' metadata (D-5). +/// +public sealed class DiffOutputTests +{ + [Fact] + public void An_entry_that_differs_only_in_running_state_is_printed_and_does_not_make_the_document_differ() + { + // The reader of Drifted that had a second question in it: "No differences." printed over an + // entry that differs only in state would hide the one thing this comparison found. + var diff = Of(changed: [Changed(DifferenceGroup.RunningState)]); + + var text = DiffText.Render(diff); + + Assert.DoesNotContain(Texts.Of("cli.diff.same"), text, StringComparison.Ordinal); + Assert.Contains("Spooler", text, StringComparison.Ordinal); + Assert.False(Document(diff).GetProperty("differs").GetBoolean()); + } + + [Fact] + public void A_configuration_difference_makes_the_document_differ() + { + Assert.True(Document(Of(changed: [Changed(DifferenceGroup.Configuration)])).GetProperty("differs").GetBoolean()); + } + + [Fact] + public void Session_copies_left_out_are_counted_in_one_line_and_in_the_document() + { + var diff = Of(changed: [], leftOut: new InstancesLeftOut(3, 2)); + + Assert.Contains(Texts.Of("cli.diff.caveat.instancesLeftOut", 3, 2), DiffText.Render(diff), StringComparison.Ordinal); + + var counted = Document(diff).GetProperty("instancesLeftOut"); + + Assert.Equal(3, counted.GetProperty("earlier").GetInt32()); + Assert.Equal(2, counted.GetProperty("later").GetInt32()); + } + + [Fact] + public void No_line_about_session_copies_when_there_were_none() + { + var text = DiffText.Render(Of(changed: [])); + + Assert.DoesNotContain(Texts.Of("cli.diff.caveat.instancesLeftOut", 0, 0), text, StringComparison.Ordinal); + Assert.Equal(Texts.Of("cli.diff.same") + Environment.NewLine, text); + } + + [Fact] + public void The_three_caveats_of_schema_five_are_said_and_written() + { + var diff = Of(changed: []) with + { + Caveats = new ComparisonCaveats(false, false, false, false, true, true, ["operatingSystemVersion", "namesLanguage"]) + }; + + var text = DiffText.Render(diff); + + Assert.Contains(Texts.Of("cli.diff.caveat.language"), text, StringComparison.Ordinal); + Assert.Contains(Texts.Of("cli.diff.caveat.account"), text, StringComparison.Ordinal); + Assert.Contains(Texts.Of("cli.diff.caveat.notKnown", "operatingSystemVersion, namesLanguage"), text, StringComparison.Ordinal); + + var caveats = Document(diff).GetProperty("caveats"); + + Assert.True(caveats.GetProperty("languageDiffers").GetBoolean()); + Assert.True(caveats.GetProperty("accountDiffers").GetBoolean()); + Assert.Equal( + ["operatingSystemVersion", "namesLanguage"], + caveats.GetProperty("notKnown").EnumerateArray().Select(name => name.GetString())); + } + + private static SnapshotDiff Of(IReadOnlyList changed, InstancesLeftOut? leftOut = null) => + new( + [], [], changed, [], [], [], + new ComparisonCaveats(false, false, false, false, false, false, []), + leftOut ?? new InstancesLeftOut(0, 0)); + + private static ChangedEntry Changed(DifferenceGroup group) => + new("Spooler", "Print Spooler", [new FieldDifference("status", group, "Running", "Stopped")], []); + + private static JsonElement Document(SnapshotDiff diff) => JsonDocument.Parse(DiffJson.Render(diff)).RootElement; +} diff --git a/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs b/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs new file mode 100644 index 0000000..e84caa3 --- /dev/null +++ b/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using Bws.Core; +using Bws.Core.Planning; +using Bws.Core.Snapshots; + +namespace Bws.Cli.Tests; + +/// +/// Every enumerated value in the plan and the comparison documents is spelled the way its +/// enumeration spells it - rule 5 of docs/03, the rule DocumentValueGuards holds for the snapshot. +/// +/// The plan and the comparison broke it from the day each was written until 2026-09-30 - +/// stability report round 2 point 8, owner's decision, a breaking change in 0.x. A helper lowering +/// the first letter wrote "outcome": "succeeded" beside "status": "Running" in ONE +/// result, and the comparison handed its naming policy to the value converter too. A script reading +/// either had to know which field was in which convention. +/// +/// Parsed, case-sensitively, rather than checked for a capital - the same reason the +/// snapshot's guard gives: a value has to BE a member, spelled exactly, or a script keying on it +/// is keying on nothing. +/// +/// Every member of every enumeration is put through the writer, so a member added later is +/// covered the day it is added - a fixture choosing three would go stale at the first new kind. +/// +public sealed class MachineDocumentValueGuards +{ + [Fact] + public void Every_enumerated_value_in_a_plan_is_a_member_spelled_as_its_enumeration_spells_it() + { + var checkedValues = 0; + + foreach (var kind in Enum.GetValues()) + { + var plan = JsonDocument.Parse(PlanJson.Render(Run(kind))).RootElement; + + checkedValues += Member(plan, "action"); + + foreach (var step in plan.GetProperty("steps").EnumerateArray()) + { + checkedValues += Member(step, "operation") + Member(step, "reason"); + } + + foreach (var warning in plan.GetProperty("warnings").EnumerateArray()) + { + checkedValues += Member(warning, "kind"); + } + + foreach (var result in plan.GetProperty("results").EnumerateArray()) + { + checkedValues += Member(result, "outcome") + Member(result, "skippedBecause"); + } + } + + // A GUARD THAT READ NOTHING PASSES - one renamed field and every check above skips itself. + Assert.True(checkedValues > 100, $"Only {checkedValues} values were looked at. A field was probably renamed."); + } + + [Fact] + public void The_group_of_every_difference_is_a_member_spelled_as_its_enumeration_spells_it() + { + var differences = Enum.GetValues() + .Select(group => new FieldDifference("field", group, "before", "after")) + .ToList(); + + var diff = new SnapshotDiff( + [], [], [new ChangedEntry("Spooler", "Print Spooler", differences, [])], [], [], [], + new ComparisonCaveats(false, false, false, false, false, false, []), + new InstancesLeftOut(0, 0)); + + var written = JsonDocument.Parse(DiffJson.Render(diff)).RootElement + .GetProperty("changed")[0].GetProperty("differences").EnumerateArray() + .Sum(difference => Member(difference, "group")); + + Assert.Equal(differences.Count, written); + } + + /// + /// A run of one plan holding a step of every operation, every reason, a warning of every kind and + /// a result of every outcome and every skip reason. + /// + private static PlanRun Run(ActionKind kind) + { + var steps = Enum.GetValues() + .Select(operation => new PlanStep("Spooler", "Print Spooler", operation, StepReason.Requested)) + .Concat(Enum.GetValues() + .Select(reason => new PlanStep("Spooler", "Print Spooler", StepOperation.Stop, reason))) + .ToList(); + + var outcomes = Enum.GetValues() + .Select(outcome => Result(steps[0], outcome, skipped: null)) + .Concat(Enum.GetValues().Select(reason => Result(steps[0], StepOutcome.Skipped, reason))) + .ToList(); + + return new PlanRun + { + Plan = new OperationPlan + { + Action = new ServiceAction(kind, "Spooler"), + Steps = steps, + Warnings = [.. Enum.GetValues().Select(warning => new PlanWarning(warning, "Spooler", ["W32Time"]))], + Problems = [] + }, + Results = outcomes, + Cancelled = false, + Ceiling = TimeSpan.FromSeconds(60) + }; + } + + private static StepResult Result(PlanStep step, StepOutcome outcome, SkipReason? skipped) => new() + { + Step = step, + Outcome = outcome, + SkippedBecause = skipped, + Status = EntryStatus.Running, + ProcessId = Reading.Present(4812), + ErrorCode = 0, + Error = null, + Milliseconds = 10 + }; + + /// One value parsed as a member of its enumeration. Null is an answer rather than a gap. + private static int Member(JsonElement holder, string field) + where T : struct, Enum + { + if (!holder.TryGetProperty(field, out var value) || value.ValueKind != JsonValueKind.String) + { + return 0; + } + + var written = value.GetString()!; + + Assert.True( + Enum.TryParse(written, ignoreCase: false, out _), + $"The document writes \"{field}\": \"{written}\", which is not how {typeof(T).Name} spells any of " + + "its members. Values in a machine readable document are written the way the enumeration " + + "writes them - rule 5 of docs/03."); + + return 1; + } +} diff --git a/tests/Bws.Cli.Tests/PlanDocumentGuards.cs b/tests/Bws.Cli.Tests/PlanDocumentGuards.cs index d7f0833..f1c8d96 100644 --- a/tests/Bws.Cli.Tests/PlanDocumentGuards.cs +++ b/tests/Bws.Cli.Tests/PlanDocumentGuards.cs @@ -49,7 +49,7 @@ public void A_step_nobody_reached_says_null_too_and_the_reason_is_beside_it() }); Assert.Equal(JsonValueKind.Null, document.GetProperty("processId").ValueKind); - Assert.Equal("earlierStepFailed", document.GetProperty("skippedBecause").GetString()); + Assert.Equal("EarlierStepFailed", document.GetProperty("skippedBecause").GetString()); } /// @@ -98,7 +98,7 @@ public void A_forced_stop_whose_kill_arrived_is_written_as_completed() // AND THE POLITE STEP IS STILL WRITTEN AS THE STEP THAT GAVE UP. The verdict changed, the // record of what happened did not - a document that tidied the timeout away would hide the // one line that says why a process was ended. - Assert.Equal("timedOut", document.GetProperty("results")[0].GetProperty("outcome").GetString()); + Assert.Equal("TimedOut", document.GetProperty("results")[0].GetProperty("outcome").GetString()); } private static JsonElement Rendered(StepResult result) => diff --git a/tests/Bws.Cli.Tests/PutBackOutputTests.cs b/tests/Bws.Cli.Tests/PutBackOutputTests.cs index cbf0961..f2f72bf 100644 --- a/tests/Bws.Cli.Tests/PutBackOutputTests.cs +++ b/tests/Bws.Cli.Tests/PutBackOutputTests.cs @@ -41,8 +41,8 @@ public void The_document_names_the_reason_and_the_warning() var document = JsonDocument.Parse(PlanJson.Render(run)).RootElement; - Assert.Equal("nothingToPutBack", document.GetProperty("results")[0].GetProperty("skippedBecause").GetString()); - Assert.Equal("restartOnlyStarts", document.GetProperty("warnings")[0].GetProperty("kind").GetString()); + Assert.Equal("NothingToPutBack", document.GetProperty("results")[0].GetProperty("skippedBecause").GetString()); + Assert.Equal("RestartOnlyStarts", document.GetProperty("warnings")[0].GetProperty("kind").GetString()); } private static StepResult Skipped(SkipReason reason) => new() diff --git a/tests/Bws.Cli.Tests/SnapshotTargetTests.cs b/tests/Bws.Cli.Tests/SnapshotTargetTests.cs new file mode 100644 index 0000000..72a041d --- /dev/null +++ b/tests/Bws.Cli.Tests/SnapshotTargetTests.cs @@ -0,0 +1,38 @@ +namespace Bws.Cli.Tests; + +/// +/// A folder named as the file a snapshot goes to - stability report D-6, measured 2026-09-29: +/// bws snapshot create <an existing folder> spent 1545 ms reading signatures and then +/// ended with "Access to the path is denied." and code 2, because the only refusal came from the +/// write itself. +/// +public sealed class SnapshotTargetTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_folder_is_refused_in_words_about_the_folder_with_or_without_force(bool force) + { + var folder = Directory.CreateTempSubdirectory("bws-target-").FullName; + + try + { + Assert.False(SnapshotFiles.MayWrite(folder, force, out var refusal)); + Assert.Equal(Texts.Of("cli.snapshot.isFolder", folder), refusal); + } + finally + { + Directory.Delete(folder); + } + } + + [Fact] + public void A_file_that_is_not_there_yet_may_be_written() + { + // The ordinary case, beside the refusal so a check that refused everything would show here. + var path = Path.Combine(Path.GetTempPath(), $"bws-target-{Guid.NewGuid():N}.json"); + + Assert.True(SnapshotFiles.MayWrite(path, force: false, out var refusal)); + Assert.Null(refusal); + } +} diff --git a/tests/Bws.Core.Tests/Bws.Core.Tests.csproj b/tests/Bws.Core.Tests/Bws.Core.Tests.csproj index 1290bbf..3577d88 100644 --- a/tests/Bws.Core.Tests/Bws.Core.Tests.csproj +++ b/tests/Bws.Core.Tests/Bws.Core.Tests.csproj @@ -32,6 +32,7 @@ --> + diff --git a/tests/Bws.Core.Tests/ComparisonCaveatTests.cs b/tests/Bws.Core.Tests/ComparisonCaveatTests.cs new file mode 100644 index 0000000..ec99b2b --- /dev/null +++ b/tests/Bws.Core.Tests/ComparisonCaveatTests.cs @@ -0,0 +1,153 @@ +using System.Globalization; +using Bws.Core.Snapshots; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// What the two snapshots' metadata add up to, schema five - stability report D-5, owner's +/// decisions of 2026-09-30. +/// +/// Three answers for each of the two added fields, and the third is the one that is easy to +/// lose: the same, different, or not known because one file does not carry it. A caveat that +/// turned "not known" into "the same" would be the silence rule 8 forbids, and one that turned it +/// into "different" would invent a reason to distrust a comparison that is fine. +/// +public sealed class ComparisonCaveatTests +{ + [Fact] + public void Two_updates_of_one_build_are_different_windows() + { + // The text written since the format began ends in ".0" on both sides, so until schema five + // these two compared as taken on the same Windows. + var caveats = Caveats(Metadata(version: "10.0.26200.9550"), Metadata(version: "10.0.26200.9551")); + + Assert.True(caveats.OperatingSystemDiffers); + Assert.Empty(caveats.NotKnown); + } + + [Fact] + public void The_same_update_on_both_sides_is_the_same_windows() + { + Assert.False(Caveats(Metadata(), Metadata()).OperatingSystemDiffers); + } + + [Fact] + public void An_update_nobody_recorded_is_not_known_rather_than_different() + { + var caveats = Caveats(Metadata(version: null), Metadata()); + + Assert.False(caveats.OperatingSystemDiffers); + Assert.Equal(["operatingSystemVersion"], caveats.NotKnown); + } + + [Fact] + public void Two_languages_leave_the_names_out_of_every_entry() + { + var diff = Between( + Metadata(language: "en-US"), + Metadata(language: "pl-PL"), + Entries.Any with { DisplayName = "Bufor wydruku" }); + + Assert.True(diff.Caveats.LanguageDiffers); + + // Not a change and not "not fully compared" on the entry either - the caveat says it once, + // rather than the same admission on every row of eight hundred. + Assert.Empty(diff.Changed); + Assert.Empty(diff.NotFullyCompared); + } + + [Fact] + public void One_language_on_both_sides_still_compares_the_names() + { + var diff = Between(Metadata(), Metadata(), Entries.Any with { DisplayName = "Something else" }); + + Assert.Equal("displayName", Assert.Single(Assert.Single(diff.Changed).Differences).Field); + } + + [Fact] + public void A_language_nobody_recorded_still_compares_the_names_and_says_it_could_not_check() + { + // The owner's decision for a version four file: compared as before, and one line saying + // what the older file does not record. + var diff = Between(Metadata(language: null), Metadata(), Entries.Any with { DisplayName = "Something else" }); + + Assert.False(diff.Caveats.LanguageDiffers); + Assert.Equal(["namesLanguage"], diff.Caveats.NotKnown); + Assert.Single(diff.Changed); + } + + [Theory] + [InlineData(@"TESTBOX\somebody", false)] + [InlineData(@"testbox\SOMEBODY", false)] + [InlineData(@"TESTBOX\someone", true)] + public void A_different_account_is_said_and_different_capitals_are_not_one(string takenBy, bool differs) + { + Assert.Equal(differs, Caveats(Metadata(), Metadata(takenBy: takenBy)).AccountDiffers); + } + + [Theory] + [InlineData(3)] + [InlineData(6)] + public void A_schema_outside_the_range_this_build_reads_is_refused_and_the_range_is_named(int version) + { + var text = SnapshotJson.Render(Snapshot.Of(Specimens.All, note: null, new FakeClock())) + .Replace($"\"schemaVersion\": {Snapshot.CurrentSchemaVersion}", $"\"schemaVersion\": {version}", StringComparison.Ordinal); + + Assert.False(SnapshotJson.TryRead(text, out _, out var failure)); + Assert.Contains("versions 4 to 5", failure!, StringComparison.Ordinal); + } + + [Fact] + public void This_machine_says_its_update_and_the_language_its_manager_names_things_in() + { + // Asked of the machine the tests run on, because the two readers ARE the machine - a fake + // here would test the fake. Windows keeps an update number on every supported release. + var version = Environment.OSVersion.Version; + var written = SystemFacts.OperatingSystemVersion(); + + // The update read here a second way, through the same documented value, because a fourth part + // that merely parses would let ".0" through - the very thing Environment.OSVersion writes. + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + var update = Assert.IsType(key?.GetValue("UBR")); + + Assert.Equal( + string.Create(CultureInfo.InvariantCulture, $"{version.Major}.{version.Minor}.{version.Build}.{update}"), + written); + + var language = SystemFacts.NamesLanguage(); + + Assert.NotNull(language); + Assert.Equal(language, CultureInfo.GetCultureInfo(language).Name, StringComparer.OrdinalIgnoreCase); + } + + /// Metadata a test controls, with the two schema five fields known unless a test says otherwise. + internal static SnapshotMetadata Metadata( + string? version = "10.0.26200.9550", string? language = "pl-PL", string takenBy = @"TESTBOX\somebody") => + new() + { + SchemaVersion = Snapshot.CurrentSchemaVersion, + Machine = "TESTBOX", + OperatingSystem = "Microsoft Windows NT 10.0.26200.0", + TakenAt = DateTimeOffset.UnixEpoch, + TakenBy = takenBy, + Elevated = true, + Note = null, + Tool = "0.1.0", + OperatingSystemVersion = version, + NamesLanguage = language + }; + + private static ComparisonCaveats Caveats(SnapshotMetadata before, SnapshotMetadata after) => + ComparisonCaveats.Between(before, after); + + private static SnapshotDiff Between(SnapshotMetadata before, SnapshotMetadata after, ScmEntry later) + { + var earlier = Snapshot.Of([Entries.Any], note: null, new FakeClock()) with { Metadata = before }; + var now = Snapshot.Of([later], note: null, new FakeClock()) with { Metadata = after }; + + Assert.True(SnapshotDiff.TryBetween(earlier, now, out var diff, out var failure), failure); + + return diff; + } +} diff --git a/tests/Bws.Core.Tests/ComparisonScopeTests.cs b/tests/Bws.Core.Tests/ComparisonScopeTests.cs new file mode 100644 index 0000000..442c28b --- /dev/null +++ b/tests/Bws.Core.Tests/ComparisonScopeTests.cs @@ -0,0 +1,121 @@ +using Bws.Core.Snapshots; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// What a comparison looks at, and what makes it answer "drifted". +/// +/// Two decisions of the owner, 2026-09-30, stability report D-1 and D-2. Per-user session +/// copies are left out of both sides and counted, because each belongs to one signed-in session and +/// comes and goes with it. And --exit-code answers about configuration only, because running +/// state has been reported apart from drift since 2026-08-01 and still decided the exit code. +/// +public sealed class ComparisonScopeTests +{ + [Fact] + public void Session_copies_are_left_out_on_both_sides_and_counted() + { + // Two sessions signed in before, one after - a copy went away and another arrived, which + // is somebody signing out and somebody else in, not a change to the machine. + var diff = Between( + [Entry("Spooler"), Copy("CDPUserSvc_1036d1"), Copy("CDPUserSvc_2e71a")], + [Entry("Spooler"), Copy("CDPUserSvc_5c01f")]); + + Assert.Empty(diff.Added); + Assert.Empty(diff.Removed); + Assert.False(diff.Drifted); + Assert.Equal(new InstancesLeftOut(2, 1), diff.LeftOut); + } + + [Fact] + public void A_copy_whose_settings_changed_is_not_compared_either() + { + // The same copy on both sides, differing - still left out, not only when it comes and goes. + var diff = Between( + [Copy("CDPUserSvc_1036d1")], + [Copy("CDPUserSvc_1036d1") with { StartType = Reading.Present(StartType.Disabled) }]); + + Assert.Empty(diff.Changed); + Assert.Empty(diff.NotFullyCompared); + Assert.Equal(new InstancesLeftOut(1, 1), diff.LeftOut); + } + + [Fact] + public void The_template_the_copies_are_made_from_is_still_compared() + { + var template = Entry("CDPUserSvc") with { PerUserRole = PerUserRole.Template }; + + var diff = Between( + [template], + [template with { StartType = Reading.Present(StartType.Disabled) }]); + + Assert.Equal("startType", Assert.Single(Assert.Single(diff.Changed).Differences).Field); + Assert.True(diff.Drifted); + Assert.False(diff.LeftOut.Any); + } + + [Fact] + public void A_name_shaped_like_a_copy_is_not_a_copy() + { + // By the role the type bits give, never by the name - a real service whose name happens + // to end in an underscore and hex digits is a service, and it appearing is drift. + var diff = Between([Entry("Spooler")], [Entry("Spooler"), Entry("Vendor_1036d1")]); + + Assert.Equal("Vendor_1036d1", Assert.Single(diff.Added).ServiceName); + Assert.False(diff.LeftOut.Any); + } + + [Fact] + public void An_entry_that_differs_only_in_running_state_is_reported_and_is_not_drift() + { + // The case --exit-code paged somebody over until 2026-09-30: nothing about how the machine + // is set up moved, a service stopped by itself. + var diff = Between([Entry("Spooler")], [Entry("Spooler") with { Status = EntryStatus.Stopped }]); + + Assert.Equal(DifferenceGroup.RunningState, Assert.Single(Assert.Single(diff.Changed).Differences).Group); + Assert.False(diff.Drifted); + + // And it is still in front of a person - the report must not say "no differences" over it. + Assert.True(diff.Reported); + } + + [Fact] + public void Running_state_beside_a_configuration_change_is_drift() + { + var diff = Between( + [Entry("Spooler")], + [Entry("Spooler") with + { + Status = EntryStatus.Stopped, + StartType = Reading.Present(StartType.Disabled) + }]); + + Assert.True(diff.Drifted); + } + + [Fact] + public void Nothing_to_report_is_said_as_nothing() + { + var diff = Between([Entry("Spooler")], [Entry("Spooler")]); + + Assert.False(diff.Reported); + Assert.False(diff.Drifted); + } + + private static SnapshotDiff Between(IReadOnlyList before, IReadOnlyList after) + { + Assert.True(SnapshotDiff.TryBetween(Taken(before), Taken(after), out var diff, out var failure), failure); + + return diff; + } + + private static Snapshot Taken(IReadOnlyList entries) => + Snapshot.Of(entries, note: null, new FakeClock()) with { Metadata = ComparisonCaveatTests.Metadata() }; + + private static ScmEntry Entry(string name) => + Entries.Any with { ServiceName = name, DisplayName = $"{name} display name" }; + + private static ScmEntry Copy(string name) => + Entry(name) with { PerUserRole = PerUserRole.Instance }; +} diff --git a/tests/Bws.Core.Tests/DamagedListTests.cs b/tests/Bws.Core.Tests/DamagedListTests.cs new file mode 100644 index 0000000..672e938 --- /dev/null +++ b/tests/Bws.Core.Tests/DamagedListTests.cs @@ -0,0 +1,44 @@ +using Bws.Core.Snapshots; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// A null among the names of fields nobody read - stability report D-3, measured 2026-09-29. +/// +/// What it did before, on the real tool: a copy of a real snapshot with "notRead": [null] +/// in one entry ended bws snapshot diff with "Object reference not set to an instance of an +/// object." and code 1 - the code for the tool falling over, on a file that is simply damaged. The +/// comparison turns each name on that list into the field it speaks about, and a null is not one. +/// +/// Structurally valid JSON, so the property test that damages snapshots by cutting and flipping +/// characters never reaches it. Both readers are asked, because both lean on the same rule. +/// +public sealed class DamagedListTests +{ + [Fact] + public void The_reader_refuses_the_file_and_names_the_entry() + { + var entry = Specimens.All.First(specimen => EntryDocument.From(specimen).NotRead is { Count: > 0 }); + + var text = SnapshotJson.Render(Snapshot.Of([entry], note: null, new FakeClock())) + .Replace("\"notRead\": [", "\"notRead\": [\n null,", StringComparison.Ordinal); + + Assert.False(SnapshotJson.TryRead(text, out var read, out var failure)); + Assert.Null(read); + Assert.Contains(entry.ServiceName, failure!, StringComparison.Ordinal); + } + + [Fact] + public void The_comparison_refuses_a_document_built_in_code_rather_than_throwing() + { + // The second caller - anything handing the engine a document that never went through the + // reader. The engine asks the same question of whatever it is given. + var fine = Snapshot.Of([Entries.Any], note: null, new FakeClock()); + var damaged = fine with { Entries = [fine.Entries[0] with { NotRead = ["triggers", null!] }] }; + + Assert.False(SnapshotDiff.TryBetween(fine, damaged, out var diff, out var failure)); + Assert.Null(diff); + Assert.Contains("later", failure!, StringComparison.Ordinal); + } +} diff --git a/tests/Bws.Core.Tests/DiffPropertyTests.cs b/tests/Bws.Core.Tests/DiffPropertyTests.cs index 364d3e3..0e1e179 100644 --- a/tests/Bws.Core.Tests/DiffPropertyTests.cs +++ b/tests/Bws.Core.Tests/DiffPropertyTests.cs @@ -44,7 +44,7 @@ public void Two_readings_of_a_machine_that_did_not_change_differ_in_nothing() { var diff = Compared(Machines.Taken(entries, at: 100), Machines.Taken(entries, at: 900)); - return !diff.Any && diff.Added.Count == 0 && diff.Removed.Count == 0 && diff.Changed.Count == 0; + return !diff.Drifted && diff.Added.Count == 0 && diff.Removed.Count == 0 && diff.Changed.Count == 0; }, iter: 5_000, print: entries => $"unchanged machine reported as changed: {Machines.Naming(entries)}"); @@ -218,7 +218,9 @@ public void Every_process_identifier_moving_at_once_is_still_not_a_difference() .Select(entry => entry with { ProcessId = Reading.Present(moved) }) .ToList(); - return !Compared(Machines.Taken(entries), Machines.Taken(rebooted)).Any; + var diff = Compared(Machines.Taken(entries), Machines.Taken(rebooted)); + + return diff.Added.Count == 0 && diff.Removed.Count == 0 && diff.Changed.Count == 0; }, iter: 5_000); } diff --git a/tests/Bws.Core.Tests/Golden/snapshot-schema-5.json b/tests/Bws.Core.Tests/Golden/snapshot-schema-5.json new file mode 100644 index 0000000..5c19a64 --- /dev/null +++ b/tests/Bws.Core.Tests/Golden/snapshot-schema-5.json @@ -0,0 +1,1309 @@ +{ + "entries": [ + { + "account": null, + "binaryFile": "C:\\WINDOWS\\System32\\DriverStore\\FileRepository\\u0202073.inf_amd64_3c7f18bc022bf004\\B026184\\amdkmdag.sys", + "binaryHash": null, + "binaryOnDisk": false, + "binaryPath": "\\SystemRoot\\System32\\DriverStore\\FileRepository\\u0202073.inf_amd64_3c7f18bc022bf004\\B026184\\amdkmdag.sys", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": null, + "displayName": "amduw23g-202073-df09ebb6", + "entryType": "KernelDriver", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": null, + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)", + "serviceName": "amduw23g-202073-df09ebb6", + "sidType": null, + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Disabled", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Informacje o aplikacji", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 1234, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "Appinfo", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "Running", + "triggers": [ + { + "action": "Start", + "kind": "NetworkEndpoint" + }, + { + "action": "Start", + "kind": "NetworkEndpoint" + }, + { + "action": "Start", + "kind": "NetworkEndpoint" + }, + { + "action": "Start", + "kind": "NetworkEndpoint" + }, + { + "action": "Start", + "kind": "NetworkEndpoint" + }, + { + "action": "Start", + "kind": "NetworkEndpoint" + } + ] + }, + { + "account": null, + "binaryFile": "C:\\WINDOWS\\system32\\drivers\\AppvStrm.sys", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "\\SystemRoot\\system32\\drivers\\AppvStrm.sys", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "AppvStrm", + "entryType": "FileSystemDriver", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "AppvStrm", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "AsusUpdateCheck", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": null, + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)", + "serviceName": "AsusUpdateCheck", + "sidType": null, + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Stopped", + "triggers": null + }, + { + "account": null, + "binaryFile": "C:\\WINDOWS\\System32\\drivers\\Beep.sys", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": null, + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": null, + "displayName": "Beep", + "entryType": "KernelDriver", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": null, + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)", + "serviceName": "Beep", + "sidType": null, + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "System", + "status": "Running", + "triggers": null + }, + { + "account": "NT AUTHORITY\\LocalService", + "binaryFile": "C:\\WINDOWS\\system32\\svchost.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\system32\\svchost.exe -k LocalServiceNoNetworkFirewall -p", + "delayedAuto": false, + "dependsOn": [ + "RpcSs" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Podstawowy aparat filtrowania", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 4296, + "requiredBy": null, + "requiredPrivileges": [ + "SeAuditPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCLORC;;;AU)(A;;CCDCLCSWRPLORCWDWO;;;SY)(A;;CCLCSWRPLORCWDWO;;;BA)(A;;CCLCLO;;;BU)", + "serviceName": "BFE", + "sidType": "Restricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": true, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Usługa inteligentnego transferu w tle", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 18044, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "BITS", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null + }, + { + "account": null, + "binaryFile": "C:\\WINDOWS\\System32\\drivers\\bthmodem.sys", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "\\SystemRoot\\System32\\drivers\\bthmodem.sys", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Sterownik komunikacyjny modemu Bluetooth", + "entryType": "KernelDriver", + "errorControl": "Normal", + "fileVersion": null, + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "BTHMODEM", + "sidType": "Unrestricted", + "signature": { + "publisher": null, + "resultCode": -2146762496, + "status": "NotSigned" + }, + "startType": "Manual", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Usługa użytkownika platformy podłączonych urządzeń", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "Template", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "CDPUserSvc", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Stopped", + "triggers": null + }, + { + "account": null, + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Usługa użytkownika platformy podłączonych urządzeń_21aaa4", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "Instance", + "processId": 5984, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "CDPUserSvc_21aaa4", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": "Keeps files, settings and mail in step between this device and the service.\r\nIf this service is stopped, anything that depends on it explicitly will fail to start, and content will stop being kept up to date until the service is started again. Stopping it does not remove anything already on this device.", + "displayName": "Contoso Sync Host", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 1234, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "ContosoSyncHost", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\Program Files\\Contoso\\VPN\\v4.4.1\\ContosoVPN.TunnelService.exe", + "binaryHash": null, + "binaryOnDisk": false, + "binaryPath": "\"C:\\Program Files\\Contoso\\VPN\\v4.4.1\\ContosoVPN.TunnelService.exe\" \"C:\\Program Files\\Contoso\\VPN\\v4.4.1\\ServiceData\\Tunnel\\ContosoVPN.conf\" \"udp\"", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "ContosoVPN Tunnel", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "ContosoVPN Tunnel", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Program uruchamiający proces serwera DCOM", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 1900, + "requiredBy": null, + "requiredPrivileges": [ + "SeAssignPrimaryTokenPrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeCreateGlobalPrivilege", + "SeDebugPrivilege", + "SeImpersonatePrivilege", + "SeIncreaseQuotaPrivilege", + "SeTcbPrivilege", + "SeBackupPrivilege", + "SeRestorePrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCLORC;;;AU)(A;;CCDCLCSWRPWPDTLORCWDWO;;;SY)(A;;CCLCSWRPWPDTLORCWDWO;;;BA)(A;;CCLCLO;;;BU)", + "serviceName": "DcomLaunch", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\Program Files (x86)\\Fabrikam\\Fabrikam Game Launcher Core\\GameElevationService.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\Program Files (x86)\\Fabrikam\\Fabrikam Game Launcher Core\\GameElevationService.exe", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Fabrikam Game Elevation Service", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "GameElevationService", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Automatic, and nobody could tell whether it is delayed", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "HalfRead", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Stopped", + "triggers": null, + "unreadable": { + "delayedAuto": { + "errorCode": 5, + "message": "access denied" + } + } + }, + { + "account": null, + "binaryFile": null, + "binaryHash": null, + "binaryOnDisk": null, + "binaryPath": null, + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Nothing about this one could be read", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": null, + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": null, + "securityDescriptor": null, + "serviceName": "Locked", + "sidType": null, + "signature": null, + "startType": null, + "status": "Stopped", + "triggers": null, + "unreadable": { + "account": { + "errorCode": 5, + "message": "access denied" + }, + "binaryFile": { + "errorCode": 5, + "message": "access denied" + }, + "binaryOnDisk": { + "errorCode": 5, + "message": "access denied" + }, + "binaryPath": { + "errorCode": 5, + "message": "access denied" + }, + "delayedAuto": { + "errorCode": 5, + "message": "access denied" + }, + "requiredPrivileges": { + "errorCode": 5, + "message": "access denied" + }, + "securityDescriptor": { + "errorCode": 5, + "message": "access denied" + }, + "sidType": { + "errorCode": 5, + "message": "access denied" + }, + "signature": { + "errorCode": 5, + "message": "access denied" + }, + "startType": { + "errorCode": 5, + "message": "access denied" + } + } + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\system32\\svchost.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\system32\\svchost.exe -k DcomLaunch -p", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Menedżer sesji lokalnej", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 1388, + "requiredBy": null, + "requiredPrivileges": null, + "securityDescriptor": null, + "serviceName": "LSM", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null, + "unreadable": { + "securityDescriptor": { + "errorCode": 5, + "message": "access denied" + } + } + }, + { + "account": "NT SERVICE\\McmSvc", + "binaryFile": "C:\\WINDOWS\\system32\\svchost.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\system32\\svchost.exe -k McmSvc -p -s McmSvc", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Usługa zarządzania łącznością mobilną", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": null, + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRRC;;;LS)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLORC;;;IU)(A;;CCLCSWLOCRRC;;;SU)", + "serviceName": "McmSvc", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "Stopped", + "triggers": null + }, + { + "account": "NT SERVICE\\OpenVPNService", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "OpenVPNService", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "OpenVPNService", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Disabled", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": null, + "binaryHash": null, + "binaryOnDisk": null, + "binaryPath": null, + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Names no file at all", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": null, + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "PathLess", + "sidType": "Unrestricted", + "signature": null, + "startType": "Manual", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Plug and Play", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 1900, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "PlugPlay", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "Running", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": null, + "dependsOn": null, + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Nothing needs to be running first", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "PlugPlayNoDeps", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "Running", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Usługa PushToInstall systemu Windows", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 17452, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "PushToInstall", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "StartPending", + "triggers": null + }, + { + "account": "localSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": null, + "dependsOn": [ + "RpcSS", + "Bfe", + "RasMan", + "Http", + "+NetBIOSGroup" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Routing i dostęp zdalny", + "entryType": "SharedProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "RemoteAccess", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Disabled", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Bufor wydruku", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 4268, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "Spooler", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null + }, + { + "account": "NT AUTHORITY\\NetworkService", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": true, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Ochrona oprogramowania", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "sppsvc", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Stopped", + "triggers": [ + { + "action": "Start", + "kind": "Custom" + }, + { + "action": "Start", + "kind": "CustomSystemStateChange" + } + ] + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": null, + "displayName": "Microsoft IPv6 Protocol Driver", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 1234, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "Tcpip6", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null, + "unreadable": { + "description": { + "errorCode": 1332, + "message": "The resource could not be found." + } + } + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": null, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "Nobody asked yet", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "triggers", + "binaryHash" + ], + "perUserRole": "None", + "processId": null, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "TriggersUnknown", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Manual", + "status": "Stopped", + "triggers": null + }, + { + "account": "LocalSystem", + "binaryFile": "C:\\WINDOWS\\System32\\spoolsv.exe", + "binaryHash": null, + "binaryOnDisk": true, + "binaryPath": "C:\\WINDOWS\\System32\\spoolsv.exe", + "delayedAuto": false, + "dependsOn": [ + "RPCSS" + ], + "description": "This service spools print jobs and handles interaction with the printer. If you turn off this service, you won't be able to print or see your printers.", + "displayName": "First twin", + "entryType": "OwnProcess", + "errorControl": "Normal", + "fileVersion": "10.0.26100.1", + "loadOrderGroup": null, + "notRead": [ + "requiredBy", + "binaryHash" + ], + "perUserRole": "None", + "processId": 1234, + "requiredBy": null, + "requiredPrivileges": [ + "SeTcbPrivilege", + "SeImpersonatePrivilege", + "SeAuditPrivilege", + "SeChangeNotifyPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeLoadDriverPrivilege" + ], + "securityDescriptor": "O:SYG:SYD:(A;;CCLCSWLOCRRC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;SY)", + "serviceName": "Twin", + "sidType": "Unrestricted", + "signature": { + "publisher": "Microsoft Windows", + "resultCode": 0, + "status": "Trusted" + }, + "startType": "Automatic", + "status": "Running", + "triggers": null + } + ], + "metadata": { + "elevated": true, + "machine": "GOLDEN", + "namesLanguage": "en-US", + "note": "before the change", + "operatingSystem": "Microsoft Windows NT 10.0.26100.0", + "operatingSystemVersion": "10.0.26100.4000", + "schemaVersion": 5, + "takenAt": "2026-09-29T12:00:00+02:00", + "takenBy": "EXAMPLE\\operator", + "tool": "0.3.0" + } +} diff --git a/tests/Bws.Core.Tests/SnapshotDiffTests.cs b/tests/Bws.Core.Tests/SnapshotDiffTests.cs index d395a5d..3b1c105 100644 --- a/tests/Bws.Core.Tests/SnapshotDiffTests.cs +++ b/tests/Bws.Core.Tests/SnapshotDiffTests.cs @@ -17,7 +17,7 @@ public void Two_readings_of_the_same_machine_differ_in_nothing() { var diff = Between(Taken([Entry("Spooler"), Entry("BFE")]), Taken([Entry("Spooler"), Entry("BFE")])); - Assert.False(diff.Any); + Assert.False(diff.Drifted); Assert.Empty(diff.Added); Assert.Empty(diff.Removed); Assert.Empty(diff.Changed); @@ -32,7 +32,7 @@ public void A_moment_apart_is_not_a_difference() var before = Taken([Entry("Spooler")]) with { Metadata = Metadata(elevated: true, at: 100) }; var after = Taken([Entry("Spooler")]) with { Metadata = Metadata(elevated: true, at: 900) }; - Assert.False(Between(before, after).Any); + Assert.False(Between(before, after).Drifted); } [Fact] @@ -56,7 +56,7 @@ public void A_privilege_spelled_differently_is_the_same_privilege() RequiredPrivileges = Reading>.Present(["SeSystemtimePrivilege"]) }])); - Assert.False(diff.Any); + Assert.False(diff.Drifted); Assert.Empty(diff.Changed); } @@ -76,7 +76,7 @@ public void The_order_the_manager_answered_in_is_not_a_difference() DependsOn = Reading>.Present(["http", "RPCSS"]) }])); - Assert.False(diff.Any); + Assert.False(diff.Drifted); } [Fact] @@ -94,7 +94,7 @@ public void A_dependency_that_really_changed_is_still_a_difference() DependsOn = Reading>.Present(["RPCSS", "http"]) }])); - Assert.True(diff.Any); + Assert.True(diff.Drifted); Assert.Equal("dependsOn", Assert.Single(Assert.Single(diff.Changed).Differences).Field); } @@ -118,7 +118,7 @@ public void A_name_the_system_matches_without_case_is_not_a_difference(string fi { var diff = Between(Taken([Written(field, was)]), Taken([Written(field, now)])); - Assert.False(diff.Any, $"{field} was reported as changed from {was} to {now}."); + Assert.False(diff.Drifted, $"{field} was reported as changed from {was} to {now}."); } [Fact] @@ -130,7 +130,7 @@ public void A_name_that_really_changed_is_still_a_difference() Taken([Written("account", "LocalSystem")]), Taken([Written("account", @"NT SERVICE\Spooler")])); - Assert.True(diff.Any); + Assert.True(diff.Drifted); Assert.Equal("account", Assert.Single(Assert.Single(diff.Changed).Differences).Field); } @@ -144,7 +144,7 @@ public void A_hash_differing_only_in_case_is_still_a_difference() Taken([Entry("Spooler") with { BinaryHash = Reading.Present(new string('a', 64)) }]), Taken([Entry("Spooler") with { BinaryHash = Reading.Present(new string('A', 64)) }])); - Assert.True(diff.Any); + Assert.True(diff.Drifted); } [Fact] @@ -177,7 +177,7 @@ public void An_entry_that_appeared_and_one_that_went_away_are_named() Assert.Equal("New", Assert.Single(diff.Added).ServiceName); Assert.Equal("Gone", Assert.Single(diff.Removed).ServiceName); - Assert.True(diff.Any); + Assert.True(diff.Drifted); } [Fact] @@ -226,7 +226,7 @@ public void A_new_process_identifier_is_never_a_difference() Taken([Entry("Spooler") with { ProcessId = Reading.Present(1234) }]), Taken([Entry("Spooler") with { ProcessId = Reading.Present(5678) }])); - Assert.False(diff.Any); + Assert.False(diff.Drifted); } [Fact] @@ -245,7 +245,7 @@ public void An_entry_only_the_elevated_snapshot_could_see_is_not_reported_as_rem Assert.Empty(diff.Removed); // And it is not drift, so a pipeline asking by exit code is not failed by it. - Assert.False(diff.Any); + Assert.False(diff.Drifted); Assert.True(diff.Caveats.ElevationDiffers); } @@ -263,7 +263,7 @@ public void An_entry_only_the_restricted_snapshot_holds_really_did_appear() Assert.Equal("Extra", Assert.Single(diff.Removed).ServiceName); Assert.Empty(diff.Uncertain); - Assert.True(diff.Any); + Assert.True(diff.Drifted); } [Fact] @@ -281,7 +281,7 @@ public void A_field_one_side_could_not_read_is_named_and_not_counted_as_a_change Assert.Equal("securityDescriptor", Assert.Single(entry.Incomparable)); Assert.Empty(entry.Differences); Assert.Empty(diff.Changed); - Assert.False(diff.Any); + Assert.False(diff.Drifted); } [Fact] @@ -301,7 +301,7 @@ public void The_same_refusal_in_two_languages_is_not_a_difference() Taken([Entry("LSM") with { SecurityDescriptor = Reading.Denied(5, "Access is denied.") }]), Taken([Entry("LSM") with { SecurityDescriptor = Reading.Denied(5, "Odmowa dostepu.") }])); - Assert.False(diff.Any); + Assert.False(diff.Drifted); Assert.Equal("securityDescriptor", Assert.Single(Assert.Single(diff.NotFullyCompared).Incomparable)); } @@ -323,7 +323,7 @@ public void An_entry_can_be_changed_and_incompletely_compared_at_once() Assert.Equal("startType", Assert.Single(entry.Differences).Field); Assert.Equal("securityDescriptor", Assert.Single(entry.Incomparable)); Assert.Empty(diff.NotFullyCompared); - Assert.True(diff.Any); + Assert.True(diff.Drifted); } [Fact] diff --git a/tests/Bws.Core.Tests/SnapshotGoldenTests.cs b/tests/Bws.Core.Tests/SnapshotGoldenTests.cs index d9f70bf..1c0e9c6 100644 --- a/tests/Bws.Core.Tests/SnapshotGoldenTests.cs +++ b/tests/Bws.Core.Tests/SnapshotGoldenTests.cs @@ -32,20 +32,27 @@ namespace Bws.Core.Tests; /// public sealed class SnapshotGoldenTests { - private const string Kept = "snapshot-schema-4.json"; + private const string Kept = "snapshot-schema-5.json"; + + /// + /// The copy kept before schema five, 2026-09-29. Not written by this build any more and still read + /// by it - the one bump that kept reading its predecessor - so it stays as the specimen of an + /// older file somebody already has on disk. + /// + private const string KeptBefore = "snapshot-schema-4.json"; [Fact] public void The_file_is_written_byte_for_byte_as_the_kept_copy() { var written = SnapshotJson.Render(Frozen()); - var kept = KeptText(); + var kept = KeptText(Kept); if (string.Equals(written, kept, StringComparison.Ordinal)) { return; } - var actual = Path.Combine(AppContext.BaseDirectory, "snapshot-schema-4.actual.json"); + var actual = Path.Combine(AppContext.BaseDirectory, "snapshot-schema-5.actual.json"); File.WriteAllText(actual, written, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); Assert.Fail( @@ -59,7 +66,7 @@ public void The_kept_copy_reads_back_and_is_written_again_unchanged() { // Reading loses nothing that writing produced. Without this the kept copy could hold a // field the reader drops on the floor, and the comparison of two snapshots would never see it. - var kept = KeptText(); + var kept = KeptText(Kept); Assert.True(SnapshotJson.TryRead(kept, out var snapshot, out var failure), failure); Assert.Equal(kept, SnapshotJson.Render(snapshot!)); @@ -72,10 +79,41 @@ public void The_kept_copy_is_of_the_schema_this_build_writes() // an old file and calling the difference a fault. The file name carries the number so // the bump has to touch both. Assert.True( - Snapshot.CurrentSchemaVersion == 4, + Snapshot.CurrentSchemaVersion == 5, $"The schema is now {Snapshot.CurrentSchemaVersion}. Keep a new copy named for it beside {Kept}."); } + /// + /// A file written by 0.3.0 still reads, and compares against one written now with nothing + /// invented - stability report D-5, owner's decision of 2026-09-30. + /// + /// The two kept copies hold the same entries, so everything the comparison reports is + /// about the format rather than the machine: the two fields version five added are "not known" + /// rather than different, and the one per-user session copy in the catalogue is left out on + /// each side and counted (D-1). + /// + [Fact] + public void A_version_four_file_is_read_and_says_what_it_does_not_know() + { + Assert.True(SnapshotJson.TryRead(KeptText(KeptBefore), out var older, out var failure), failure); + + Assert.Equal(4, older!.Metadata.SchemaVersion); + Assert.Null(older.Metadata.OperatingSystemVersion); + Assert.Null(older.Metadata.NamesLanguage); + + Assert.True(SnapshotJson.TryRead(KeptText(Kept), out var newer, out failure), failure); + Assert.True(SnapshotDiff.TryBetween(older, newer!, out var diff, out failure), failure); + + // Not "nothing reported" - the catalogue holds refused fields on purpose, and those are named + // as not fully compared on any pair. What must be empty is anything claiming a change. + Assert.False(diff.Drifted); + Assert.Empty(diff.Changed); + Assert.Equal(["operatingSystemVersion", "namesLanguage"], diff.Caveats.NotKnown); + Assert.False(diff.Caveats.OperatingSystemDiffers); + Assert.False(diff.Caveats.LanguageDiffers); + Assert.Equal(new InstancesLeftOut(1, 1), diff.LeftOut); + } + // Without the second of the two names that differ only in case. The manager compares names // without case, so no machine produces that pair, and the reader refuses a file holding it // (Snapshot.BrokenEntries) - a kept copy with both would pin a state that cannot be read back. @@ -90,14 +128,16 @@ [.. Specimens.Inspected.Where(entry => entry.ServiceName != Specimens.CaseOnlyDi { Metadata = new SnapshotMetadata { - SchemaVersion = 4, + SchemaVersion = 5, Machine = "GOLDEN", OperatingSystem = "Microsoft Windows NT 10.0.26100.0", TakenAt = new DateTimeOffset(2026, 9, 29, 12, 0, 0, TimeSpan.FromHours(2)), TakenBy = @"EXAMPLE\operator", Elevated = true, Note = "before the change", - Tool = "0.3.0" + Tool = "0.3.0", + OperatingSystemVersion = "10.0.26100.4000", + NamesLanguage = "en-US" } }; @@ -109,10 +149,10 @@ [.. Specimens.Inspected.Where(entry => entry.ServiceName != Specimens.CaseOnlyDi /// would get, so it has to fail here rather than be skipped over quietly. The line endings /// survive a checkout because .gitattributes marks the folder as not text. /// - private static string KeptText() + private static string KeptText(string name) { - using var stream = typeof(SnapshotGoldenTests).Assembly.GetManifestResourceStream(Kept) - ?? throw new InvalidOperationException($"{Kept} is not embedded in the test assembly."); + using var stream = typeof(SnapshotGoldenTests).Assembly.GetManifestResourceStream(name) + ?? throw new InvalidOperationException($"{name} is not embedded in the test assembly."); using var reader = new StreamReader( stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), diff --git a/tests/Bws.Integration.Tests/DiffContractTests.cs b/tests/Bws.Integration.Tests/DiffContractTests.cs index fbaaab0..3f1f6f4 100644 --- a/tests/Bws.Integration.Tests/DiffContractTests.cs +++ b/tests/Bws.Integration.Tests/DiffContractTests.cs @@ -82,7 +82,7 @@ public void A_snapshot_taken_now_has_no_configuration_differences_against_the_ma var configuration = document.GetProperty("changed").EnumerateArray() .SelectMany(entry => entry.GetProperty("differences").EnumerateArray()) - .Where(difference => difference.GetProperty("group").GetString() == "configuration") + .Where(difference => difference.GetProperty("group").GetString() == "Configuration") .Select(difference => difference.GetProperty("field").GetString()) .ToArray(); @@ -91,6 +91,16 @@ public void A_snapshot_taken_now_has_no_configuration_differences_against_the_ma "A snapshot compared against the machine it was just taken from reported " + $"configuration drift: {string.Join(", ", configuration)}"); + // And the one boolean a pipeline reads says the same, whatever started or stopped in the + // minute between - since 2026-09-30 it answers about configuration only (D-2). Until + // then this test could not ask it, for the reason the comment at the top gives. + Assert.False(document.GetProperty("differs").GetBoolean()); + + // Both sides were written by this build on this machine, so both know the Windows update + // and the language the manager names things in (D-5). A build that stopped reading + // either would land here as "not known" rather than as silence. + Assert.Empty(document.GetProperty("caveats").GetProperty("notKnown").EnumerateArray()); + // And the comparison really had something to compare. Every assertion above is // satisfied by two empty snapshots agreeing perfectly, so the count comes from // the file itself rather than from the comparison's own account of its work. @@ -147,6 +157,57 @@ public void A_snapshot_taken_now_has_no_configuration_differences_against_the_ma } } + /// + /// Code 5 is drift, and running state is not drift - stability report D-2, owner's decision of + /// 2026-09-30. Until then a nightly --exit-code ended with 5 over an entry that had only + /// stopped by itself. + /// + /// Nothing in the suite ran the exit code at all before this, so the change of its meaning + /// had nothing to redden. One real snapshot and two copies edited in one field each, so the only + /// thing that differs between the two runs is which group the field belongs to. + /// + [Fact] + public void The_exit_code_answers_about_configuration_and_never_about_running_state_alone() + { + var original = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"bws-exitcode-{Guid.NewGuid():N}.json"); + var stateOnly = original.Replace(".json", "-state.json", StringComparison.Ordinal); + var setUp = original.Replace(".json", "-setup.json", StringComparison.Ordinal); + + try + { + Assert.Equal(0, CommandLineTool.Run("snapshot", "create", original).ExitCode); + + File.WriteAllText(stateOnly, Edited(original, ("status", "Running", "Stopped"))); + File.WriteAllText(setUp, Edited(original, ("startType", "Manual", "Disabled"))); + + var state = CommandLineTool.Run("snapshot", "diff", original, stateOnly, "--exit-code"); + + Assert.Equal(0, state.ExitCode); + Assert.Contains("Stopped", state.StandardOutput, StringComparison.Ordinal); + + Assert.Equal(5, CommandLineTool.Run("snapshot", "diff", original, setUp, "--exit-code").ExitCode); + } + finally + { + foreach (var file in new[] { original, stateOnly, setUp }.Where(File.Exists)) + { + File.Delete(file); + } + } + } + + /// The snapshot with one field of the first entry holding the given value changed. + private static string Edited(string path, (string Field, string From, string To) change) + { + var document = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(path))!; + var entry = document["entries"]!.AsArray() + .First(candidate => candidate![change.Field]?.GetValue() == change.From)!; + + entry[change.Field] = change.To; + + return document.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + /// A path inside the temporary directory, which is where a stray file would do least harm. private static string Somewhere(string name) => System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"bws-missing-{Guid.NewGuid():N}-{name}"); diff --git a/tests/Bws.Integration.Tests/PlanContractTests.cs b/tests/Bws.Integration.Tests/PlanContractTests.cs index 44aef3d..9071b86 100644 --- a/tests/Bws.Integration.Tests/PlanContractTests.cs +++ b/tests/Bws.Integration.Tests/PlanContractTests.cs @@ -38,8 +38,8 @@ public void Whether_an_entry_takes_a_stop_is_what_the_system_says_it_is() var refuses = OneRunningService(takesAStop: false); var takes = OneRunningService(takesAStop: true); - Assert.Contains(PlanWarnings(refuses), kind => kind == "doesNotAcceptStop"); - Assert.DoesNotContain(PlanWarnings(takes), kind => kind == "doesNotAcceptStop"); + Assert.Contains(PlanWarnings(refuses), kind => kind == "DoesNotAcceptStop"); + Assert.DoesNotContain(PlanWarnings(takes), kind => kind == "DoesNotAcceptStop"); } private static string OneRunningService(bool takesAStop) @@ -180,8 +180,8 @@ public void Carrying_out_a_plan_with_nothing_left_to_do_reports_it_as_done() Assert.True(document.GetProperty("completed").GetBoolean()); Assert.False(document.GetProperty("cancelled").GetBoolean()); - Assert.Equal("skipped", result.GetProperty("outcome").GetString()); - Assert.Equal("alreadyThere", result.GetProperty("skippedBecause").GetString()); + Assert.Equal("Skipped", result.GetProperty("outcome").GetString()); + Assert.Equal("AlreadyThere", result.GetProperty("skippedBecause").GetString()); Assert.Equal("Stopped", result.GetProperty("status").GetString()); // And the machine agrees, according to something that is not us. diff --git a/tests/Bws.Integration.Tests/SnapshotContractTests.cs b/tests/Bws.Integration.Tests/SnapshotContractTests.cs index 6bb1ba3..c8791d6 100644 --- a/tests/Bws.Integration.Tests/SnapshotContractTests.cs +++ b/tests/Bws.Integration.Tests/SnapshotContractTests.cs @@ -113,7 +113,16 @@ public void The_file_says_what_it_needs_to_be_compared_later() // Written as the literal rather than as the constant, which is the point of pinning it: a // test reading Snapshot.CurrentSchemaVersion agrees with any value that constant takes, // including one somebody moved without meaning to. - Assert.Equal(4, metadata.GetProperty("schemaVersion").GetInt32()); + // + // FIVE SINCE 2026-09-30, stability report D-5: the Windows version down to the monthly update + // and the language the manager names things in joined the metadata. The first bump that kept + // reading the version before it - a four still compares, with both fields "not known". + Assert.Equal(5, metadata.GetProperty("schemaVersion").GetInt32()); + Assert.StartsWith( + $"{Environment.OSVersion.Version.Major}.{Environment.OSVersion.Version.Minor}.{Environment.OSVersion.Version.Build}.", + metadata.GetProperty("operatingSystemVersion").GetString()!, + StringComparison.Ordinal); + Assert.NotEmpty(metadata.GetProperty("namesLanguage").GetString()!); Assert.Equal("before the deployment", metadata.GetProperty("note").GetString()); Assert.Equal(Environment.MachineName, metadata.GetProperty("machine").GetString()); Assert.NotEmpty(metadata.GetProperty("takenBy").GetString()!);