From c16784b1b9608fd5aa72f72d833d6f41aae001aa Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 27 Oct 2026 19:36:08 +0100 Subject: [PATCH 1/3] Keep each trigger whole in snapshots and machine output A trigger now carries its subtype - the device interface class, the ETW provider, or the documented constant saying which half of a kind it is - and its data items, beside the kind and the action. Until now a snapshot held the kind and the action only, so two machines whose triggers waited for different RPC interfaces or devices compared as the same. Snapshots move to schema 7 and versions 4 to 6 are still read. Each data item is a type and a list of values written as text: a run of strings as several values, bytes as lower case hexadecimal, a level in decimal, a keyword as 0x and sixteen digits. The same shape is in list --json and show --json, since both use one entry document. The reading walks three levels of pointers inside one block the manager filled. A subtype or a data item outside that block, or a size its type does not allow, refuses the whole field with ERROR_INVALID_DATA rather than dropping the item, because a trigger missing one of its items waits for something else. Triggers are compared without regard to order but with case, because a network endpoint trigger fires on an exact match of its data. A version 7 snapshot beside an older one compares triggers by kind and action only and says so once, in the text, in the window and as triggerDetailDiffers at the end of the caveats of diff --json. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 + src/Bws.Cli/DiffJson.cs | 10 +- src/Bws.Cli/DiffText.cs | 1 + src/Bws.Cli/Resources/cli.en.json | 1 + src/Bws.Core/ManagerBlocks.Triggers.cs | 248 +++ src/Bws.Core/ManagerBlocks.cs | 89 +- src/Bws.Core/ServiceTrigger.cs | 64 +- src/Bws.Core/Snapshots/ComparisonCaveats.cs | 16 + src/Bws.Core/Snapshots/EntryDocument.cs | 37 +- src/Bws.Core/Snapshots/Snapshot.cs | 11 +- src/Bws.Core/Snapshots/SnapshotDiff.Values.cs | 65 +- src/Bws.Core/Snapshots/SnapshotDiff.cs | 12 +- src/Bws.Gui/Resources/gui.en.json | 2 + src/Bws.Gui/ViewModels/DiffCaveats.cs | 3 +- .../PublicSurfaceGuards.cs | 5 + tests/Bws.Architecture.Tests/ShapeCeilings.cs | 18 +- tests/Bws.Cli.Tests/AsciiDocumentTests.cs | 2 +- tests/Bws.Cli.Tests/DiffOutputTests.cs | 33 +- .../MachineDocumentValueGuards.cs | 2 +- tests/Bws.Cli.Tests/TerminalTextTests.cs | 2 +- tests/Bws.Core.Tests/Bws.Core.Tests.csproj | 1 + tests/Bws.Core.Tests/DocumentValueGuards.cs | 37 +- tests/Bws.Core.Tests/Fakes/Specimens.cs | 52 +- .../Golden/snapshot-schema-7.json | 1410 +++++++++++++++++ tests/Bws.Core.Tests/SnapshotGoldenTests.cs | 82 +- tests/Bws.Core.Tests/TriggerBlockTests.cs | 223 +++ tests/Bws.Gui.Tests/TwoSnapshotsTests.cs | 17 + .../SnapshotContractTests.cs | 5 +- tests/mutation/entries.ps1 | 68 +- 29 files changed, 2380 insertions(+), 147 deletions(-) create mode 100644 src/Bws.Core/ManagerBlocks.Triggers.cs create mode 100644 tests/Bws.Core.Tests/Golden/snapshot-schema-7.json create mode 100644 tests/Bws.Core.Tests/TriggerBlockTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a44e71f3..07f2fb7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,17 @@ is not part of this repository. read". - The window cannot be made narrower than 1000 points, up from 900, so the new button fits in the bar of actions. +- A snapshot now keeps what exactly each trigger waits for: its subtype - the device class, the + event provider, or which of a kind's two halves it is, such as a port opening rather than closing - + and its data, such as the RPC interface, the hardware ID or the port and protocol. Until now it kept + only the kind and the action, so two machines whose triggers waited for different things compared as + the same. Snapshots are now schema version 7, and this version still reads versions 4 to 6. A + comparison of a version 7 snapshot with an older one compares triggers by kind and action only and + says so once. Between two version 7 snapshots the data is compared exactly, including upper and lower + case, because Windows matches some of it exactly. The same subtype and data are in `bws list --json` + and `bws show --json`, as `subtype` and a `data` list of items with a `type` and their `values`. In + the JSON of a comparison the new caveat is `triggerDetailDiffers`, at the end of `caveats`. An older + version of this tool refuses a version 7 snapshot by name. ### Fixed diff --git a/src/Bws.Cli/DiffJson.cs b/src/Bws.Cli/DiffJson.cs index 94459862..ff21503f 100644 --- a/src/Bws.Cli/DiffJson.cs +++ b/src/Bws.Cli/DiffJson.cs @@ -49,7 +49,8 @@ internal static string Render(SnapshotDiff diff) => diff.Caveats.LanguageDiffers, diff.Caveats.AccountDiffers, diff.Caveats.NotKnown, - diff.Caveats.FileVersionSourceDiffers), + diff.Caveats.FileVersionSourceDiffers, + diff.Caveats.TriggerDetailDiffers), [.. diff.Added.Select(Presence)], [.. diff.Removed.Select(Presence)], [.. diff.Uncertain.Select(Presence)], @@ -128,6 +129,10 @@ private sealed record RefusedDocument(string Field, IReadOnlyList Entrie /// document only grew at its end. True when one side is schema six and the other older: file /// versions were then not compared on any entry. /// + /// + /// Added 2026-10-27 (logic report C01, schema seven) and LAST, for the same reason. True when one side + /// is schema seven and the other older: triggers were then compared by kind and action only. + /// private sealed record CaveatsDocument( bool ElevationDiffers, bool MachineDiffers, @@ -136,7 +141,8 @@ private sealed record CaveatsDocument( bool LanguageDiffers, bool AccountDiffers, IReadOnlyList NotKnown, - bool FileVersionSourceDiffers); + bool FileVersionSourceDiffers, + bool TriggerDetailDiffers); private sealed record LeftOutDocument(int Earlier, int Later); diff --git a/src/Bws.Cli/DiffText.cs b/src/Bws.Cli/DiffText.cs index a48531bb..a1512531 100644 --- a/src/Bws.Cli/DiffText.cs +++ b/src/Bws.Cli/DiffText.cs @@ -79,6 +79,7 @@ private static void Caveats(StringBuilder text, SnapshotDiff diff) (caveats.LanguageDiffers, () => Texts.Of("cli.diff.caveat.language")), (caveats.AccountDiffers, () => Texts.Of("cli.diff.caveat.account")), (caveats.FileVersionSourceDiffers, () => Texts.Of("cli.diff.caveat.fileVersion")), + (caveats.TriggerDetailDiffers, () => Texts.Of("cli.diff.caveat.triggers")), (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)) diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 3d7a9d7d..49823ed8 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -337,6 +337,7 @@ "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.fileVersion": "One snapshot was written before this tool read each file's version from the file itself - older versions could take it from a language file beside it - so file versions were not compared.", + "cli.diff.caveat.triggers": "One snapshot was written before this tool kept what exactly each trigger waits for, so triggers were compared by kind and action only.", "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": "On at least one entry neither snapshot holds these fields, so those entries were not compared on them: {0}", diff --git a/src/Bws.Core/ManagerBlocks.Triggers.cs b/src/Bws.Core/ManagerBlocks.Triggers.cs new file mode 100644 index 00000000..1b4056c1 --- /dev/null +++ b/src/Bws.Core/ManagerBlocks.Triggers.cs @@ -0,0 +1,248 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Runtime.InteropServices; +using Windows.Win32.System.Services; + +namespace Bws.Core; + +/// +/// The trigger walk: the records of a trigger block, and since 2026-10-27 everything each record points +/// at - its subtype and its data items (logic report C01, snapshot schema seven). +/// +/// Its own file since that day, because the walk grew from one array to three levels of pointers +/// into one block, and it is one subject with one way of going wrong: every level is a count or a pointer +/// the manager wrote, and none of them is believed until it is checked against the block. +/// +internal static partial class ManagerBlocks +{ + /// + /// The triggers inside a block the manager has already filled. + /// + /// Its own method so that a test can hand it a block, which is the only way anything below + /// can be checked at all. The half above needs a service handle and therefore a machine + /// with a service on it - this half needs bytes, and the bytes that matter are the ones a + /// machine does not produce on demand. Same shape as and + /// , for the same reason. That sentence named those + /// readers as "left in WindowsScmCatalog" until 2026-09-02, and the last of them arrived in this + /// class that day - the seam was named in this header long before it was taken, and what + /// finally took it was the size ratchet on the file they were leaving. + /// + /// BOUNDED BY THE BLOCK SINCE 2026-08-26, and until then this walked a count and a pointer + /// that both came from the manager, with neither checked against the block they describe. + /// Every other buffer here limits itself - by the pointer landing + /// inside, the enumeration and configuration buffers by their own length. This was the one + /// left, and it is the one where being wrong costs the most: a record past the end is an access + /// violation, which ends the process outright with no report, possibly in the middle of listing + /// eight hundred entries on somebody else's server. Owner's decision of 2026-08-03, applied in + /// the last place that had not had it. + /// + /// The pointer is checked as well as the count, because either can be wrong on its own. + /// It closes a second way this could have gone wrong as well: the structure points into the + /// very block the call filled, so an array that moved between that call and the fixed statement + /// below would leave an offset nowhere near the buffer - refused here rather than followed. + /// + /// A block that does not describe itself comes back as nothing read rather than as no + /// triggers. An empty list would be a claim about the service - that it declares none - + /// and that claim is exactly what could not be established. + /// + /// What a record points at is refused rather than held down, unlike the record count + /// (2026-10-27). A count of records larger than the block keeps the records that fit, as it has + /// since 2026-08-26. A subtype or a data item outside the block refuses the whole field with + /// ERROR_INVALID_DATA, the way a configuration field outside its block is refused since R-4 - + /// because a trigger with one of its data items quietly dropped is a different trigger: a device + /// trigger fires on any of its hardware IDs and an ETW trigger on its filters, so one item fewer + /// widens or narrows what the entry waits for, and the reading would say so as fact. + /// + internal static unsafe Reading> ReadTriggerBuffer(byte[] buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + + if (buffer.Length < sizeof(SERVICE_TRIGGER_INFO)) + { + return Reading>.Absent(); + } + + fixed (byte* start = buffer) + { + var info = *(SERVICE_TRIGGER_INFO*)start; + + if (info.cTriggers == 0) + { + // A fact about the service: most entries have none. + return Reading>.Absent(); + } + + var offset = (byte*)info.pTriggers - start; + + if (info.pTriggers is null || offset < 0 || offset >= buffer.Length) + { + return Reading>.Absent(); + } + + var room = (uint)((buffer.Length - offset) / sizeof(SERVICE_TRIGGER)); + var count = Math.Min(info.cTriggers, room); + + if (count == 0) + { + return Reading>.Absent(); + } + + var triggers = new List((int)count); + + // READ THROUGH THE OFFSET THAT WAS CHECKED, NOT THROUGH THE POINTER THAT WAS NOT, and + // until 2026-09-02 this loop did the second. The check above establishes that the + // offset lands inside the block, and then the reading went back to info.pTriggers - + // so in the one case the check exists for, a pointer landing in range by accident, it + // validated one address and dereferenced another. A guard that looks like a guard and + // is not is worse than no guard, because the next reader stops looking. Backlog 297. + var records = (SERVICE_TRIGGER*)(start + offset); + + return Records(records, count, start, buffer.Length, triggers) + ? Reading>.Present(triggers) + : OutsideTheBlock>(); + } + } + + /// Every record whole into , or false at the first that is not. + private static unsafe bool Records( + SERVICE_TRIGGER* records, uint count, byte* block, int length, List triggers) + { + for (uint index = 0; index < count; index++) + { + if (Whole(records[index], block, length) is not { } trigger) + { + return false; + } + + triggers.Add(trigger); + } + + return true; + } + + /// + /// One record with its subtype and its data items, or null when anything it points at is outside + /// the block or is not the size its type says. + /// + private static unsafe ServiceTrigger? Whole(SERVICE_TRIGGER record, byte* block, int length) + { + Guid? subtype = null; + + if (record.pTriggerSubtype is not null) + { + if (Within(record.pTriggerSubtype, sizeof(Guid), block, length) is not { } at) + { + return null; + } + + subtype = *(Guid*)(block + at); + } + + var data = Items(record.cDataItems, record.pDataItems, block, length); + + return data is null + ? null + : new ServiceTrigger( + ManagerTerms.Trigger(record.dwTriggerType), ManagerTerms.TriggerAction(record.dwAction), subtype, data); + } + + /// The data items of one record, in the manager's order, or null when one cannot be read whole. + private static unsafe List? Items( + uint count, SERVICE_TRIGGER_SPECIFIC_DATA_ITEM* items, byte* block, int length) + { + if (count == 0) + { + return []; + } + + // The whole array inside the block before any of it is read - a count that does not fit is a + // block that does not describe itself, and the list is not started on a guess. + if (Within(items, (long)count * sizeof(SERVICE_TRIGGER_SPECIFIC_DATA_ITEM), block, length) is not { } at) + { + return null; + } + + var records = (SERVICE_TRIGGER_SPECIFIC_DATA_ITEM*)(block + at); + var data = new List((int)count); + + for (uint index = 0; index < count; index++) + { + var item = records[index]; + + // An item of no bytes points at nothing worth checking - the manager may leave it null. + var from = item.cbData == 0 ? 0 : Within(item.pData, item.cbData, block, length); + + if (from is null + || Value(item.dwDataType, new ReadOnlySpan(block + from.Value, (int)item.cbData)) is not { } value) + { + return null; + } + + data.Add(value); + } + + return data; + } + + /// + /// Where a run of bytes the manager pointed at starts inside the block, or null when any byte of + /// it lies outside - a null pointer included, which lands below the block. + /// + private static unsafe long? Within(void* pointer, long size, byte* block, int length) + { + var offset = (byte*)pointer - block; + + return offset >= 0 && offset <= length && size <= length - offset ? offset : null; + } + + /// + /// One data item as text, in the form describes - or null for a level + /// that is not one byte, a keyword that is not eight, or a string of an odd number of bytes. None + /// of those is a shape the documentation allows, so each is a block that does not describe itself. + /// + private static TriggerData? Value(SERVICE_TRIGGER_SPECIFIC_DATA_ITEM_DATA_TYPE type, ReadOnlySpan bytes) => type switch + { + SERVICE_TRIGGER_SPECIFIC_DATA_ITEM_DATA_TYPE.SERVICE_TRIGGER_DATA_TYPE_BINARY => + new TriggerData(TriggerDataType.Binary, [Convert.ToHexStringLower(bytes)]), + + SERVICE_TRIGGER_SPECIFIC_DATA_ITEM_DATA_TYPE.SERVICE_TRIGGER_DATA_TYPE_STRING => + bytes.Length % sizeof(char) == 0 ? new TriggerData(TriggerDataType.String, Strings(bytes)) : null, + + SERVICE_TRIGGER_SPECIFIC_DATA_ITEM_DATA_TYPE.SERVICE_TRIGGER_DATA_TYPE_LEVEL => + bytes.Length == 1 + ? new TriggerData(TriggerDataType.Level, [bytes[0].ToString(CultureInfo.InvariantCulture)]) + : null, + + SERVICE_TRIGGER_SPECIFIC_DATA_ITEM_DATA_TYPE.SERVICE_TRIGGER_DATA_TYPE_KEYWORD_ANY => Keyword(TriggerDataType.KeywordAny, bytes), + SERVICE_TRIGGER_SPECIFIC_DATA_ITEM_DATA_TYPE.SERVICE_TRIGGER_DATA_TYPE_KEYWORD_ALL => Keyword(TriggerDataType.KeywordAll, bytes), + + // A type the documentation does not name: what is there is kept, and the type says it is not ours. + _ => new TriggerData(TriggerDataType.Unknown, [Convert.ToHexStringLower(bytes)]) + }; + + private static TriggerData? Keyword(TriggerDataType type, ReadOnlySpan bytes) => + bytes.Length == sizeof(ulong) + ? new TriggerData(type, ["0x" + BinaryPrimitives.ReadUInt64LittleEndian(bytes).ToString("x16", CultureInfo.InvariantCulture)]) + : null; + + /// + /// The strings of one item: split at every null, the nulls that close the run taken off the end, and + /// one empty string when nothing is left - so an item always has a value to read. + /// + /// Split at every null rather than stopped at the first pair, the way + /// stops: this run has its own length, so an empty string in the middle is something the manager holds + /// rather than the end of the run, and keeping it is what keeps the item whole. + /// + private static string[] Strings(ReadOnlySpan bytes) + { + var values = new string(MemoryMarshal.Cast(bytes)).Split('\0'); + var kept = values.Length; + + while (kept > 1 && values[kept - 1].Length == 0) + { + kept--; + } + + return values[..kept]; + } +} diff --git a/src/Bws.Core/ManagerBlocks.cs b/src/Bws.Core/ManagerBlocks.cs index 735f1111..24d16e6c 100644 --- a/src/Bws.Core/ManagerBlocks.cs +++ b/src/Bws.Core/ManagerBlocks.cs @@ -27,95 +27,8 @@ namespace Bws.Core; /// on purpose. This half needs bytes, and a test can write the bytes that a machine will not /// produce on request. /// -internal static class ManagerBlocks +internal static partial class ManagerBlocks { - /// - /// The triggers inside a block the manager has already filled. - /// - /// Its own method so that a test can hand it a block, which is the only way anything below - /// can be checked at all. The half above needs a service handle and therefore a machine - /// with a service on it - this half needs bytes, and the bytes that matter are the ones a - /// machine does not produce on demand. Same shape as beside it - /// and as below it, for the same reason. That sentence - /// named those readers as "left in WindowsScmCatalog" until 2026-09-02, and the last of them - /// arrived here that day - the seam was named in this header long before it was taken, and - /// what finally took it was the size ratchet on the file they were leaving. - /// - /// BOUNDED BY THE BLOCK SINCE 2026-08-26, and until then this walked a count and a pointer - /// that both came from the manager, with neither checked against the block they describe. - /// Every other buffer here limits itself - by the pointer landing - /// inside, the enumeration and configuration buffers by their own length. This was the one - /// left, and it is the one where being wrong costs the most: a record past the end is an access - /// violation, which ends the process outright with no report, possibly in the middle of listing - /// eight hundred entries on somebody else's server. Owner's decision of 2026-08-03, applied in - /// the last place that had not had it. - /// - /// The pointer is checked as well as the count, because either can be wrong on its own. - /// It closes a second way this could have gone wrong as well: the structure points into the - /// very block the call filled, so an array that moved between that call and the fixed statement - /// below would leave an offset nowhere near the buffer - refused here rather than followed. - /// - /// A block that does not describe itself comes back as nothing read rather than as no - /// triggers. An empty list would be a claim about the service - that it declares none - - /// and that claim is exactly what could not be established. - /// - internal static unsafe Reading> ReadTriggerBuffer(byte[] buffer) - { - ArgumentNullException.ThrowIfNull(buffer); - - if (buffer.Length < sizeof(SERVICE_TRIGGER_INFO)) - { - return Reading>.Absent(); - } - - fixed (byte* start = buffer) - { - var info = *(SERVICE_TRIGGER_INFO*)start; - - if (info.cTriggers == 0) - { - // A fact about the service: most entries have none. - return Reading>.Absent(); - } - - var offset = (byte*)info.pTriggers - start; - - if (info.pTriggers is null || offset < 0 || offset >= buffer.Length) - { - return Reading>.Absent(); - } - - var room = (uint)((buffer.Length - offset) / sizeof(SERVICE_TRIGGER)); - var count = Math.Min(info.cTriggers, room); - - if (count == 0) - { - return Reading>.Absent(); - } - - var triggers = new List((int)count); - - // READ THROUGH THE OFFSET THAT WAS CHECKED, NOT THROUGH THE POINTER THAT WAS NOT, and - // until 2026-09-02 this loop did the second. The check above establishes that the - // offset lands inside the block, and then the reading went back to info.pTriggers - - // so in the one case the check exists for, a pointer landing in range by accident, it - // validated one address and dereferenced another. A guard that looks like a guard and - // is not is worse than no guard, because the next reader stops looking. Backlog 297. - var records = (SERVICE_TRIGGER*)(start + offset); - - for (uint index = 0; index < count; index++) - { - var trigger = records[index]; - - triggers.Add(new ServiceTrigger( - ManagerTerms.Trigger(trigger.dwTriggerType), - ManagerTerms.TriggerAction(trigger.dwAction))); - } - - return Reading>.Present(triggers); - } - } - /// /// Reads one of the manager's multi-strings: values back to back, each ending in a /// null, the whole run ending in a second one. diff --git a/src/Bws.Core/ServiceTrigger.cs b/src/Bws.Core/ServiceTrigger.cs index beeeabd9..10b0d778 100644 --- a/src/Bws.Core/ServiceTrigger.cs +++ b/src/Bws.Core/ServiceTrigger.cs @@ -55,12 +55,66 @@ public enum TriggerAction Stop } +/// What one data item of a trigger holds, in our words for the manager's five types. +public enum TriggerDataType +{ + /// A type the documentation does not name. Its bytes are kept, written as hexadecimal. + Unknown = 0, + + /// Bytes, written as lower case hexadecimal with nothing between them. + Binary, + + /// One string, or several back to back - the port, protocol and program of a firewall trigger. + String, + + /// One byte, an event level, written in decimal. + Level, + + /// A 64 bit keyword mask where any bit is enough, written as 0x and sixteen hexadecimal digits. + KeywordAny, + + /// A 64 bit keyword mask where every bit is needed, written the same way. + KeywordAll +} + +/// +/// One data item of a trigger: which device, which port, which RPC interface, which event. +/// +/// Always at least one value, and text for every type (owner's decision of 2026-10-27, logic +/// report C01). Several only when the manager holds several strings in one item, which on the machine +/// this was written on is the two firewall port triggers - 500, UDP, the program and the +/// service. sc qtriggerinfo joins those with semicolons, and that is a lossy form: a path may hold +/// a semicolon. The bytes, the level and the keyword are one value each, in a form this program writes - +/// hexadecimal rather than Base64, because Base64 tells upper case from lower and hexadecimal written +/// lower case has nothing for a comparison to fold. +/// +/// Strings are kept exactly as the manager holds them. The documentation says a network endpoint +/// trigger fires when "the string is an exact match", and of the 96 network endpoint data items on the +/// machine this was written on, 38 are upper case, 41 lower case and some mix the two inside one value - +/// so case is something the manager hands over, and a change of it may change whether the trigger fires. +/// +public sealed record TriggerData(TriggerDataType Type, IReadOnlyList Values); + /// /// One condition under which the manager starts or stops an entry by itself. /// -/// Kind and action only, for now. A trigger also carries a subtype and its own data items - -/// which device class, which port, which event - and those belong to the details panel -/// rather than to a listing. Said here rather than left to be noticed: this type answers -/// "is it trigger started and by what sort of thing", not "by exactly what". +/// Whole since 2026-10-27 (logic report C01, snapshot schema seven): until then it held the kind +/// and the action only, and said so. A snapshot of it could not tell one RPC interface from another, or +/// one device class from the next, so two machines whose triggers differed in exactly what they wait for +/// compared as the same. +/// +/// The subtype is a GUID the manager points at - the device interface class, the ETW provider, +/// or one of the documented constants that say which half of a kind this is (a port opening rather than +/// closing, the first IP address rather than the last). Null when the manager points at none, which no +/// trigger on the machine this was written on does. The data is empty for the kinds that carry none. +/// +/// The two new members have defaults because a trigger with neither is a real thing to build - +/// the specimens and tests that describe what sort of trigger an entry has, and nothing more, say exactly +/// that and no more. /// -public sealed record ServiceTrigger(TriggerKind Kind, TriggerAction Action); +public sealed record ServiceTrigger( + TriggerKind Kind, TriggerAction Action, Guid? Subtype = null, IReadOnlyList? Data = null) +{ + /// The data items in the order the manager holds them, empty when there are none. + public IReadOnlyList Data { get; init; } = Data ?? []; +} diff --git a/src/Bws.Core/Snapshots/ComparisonCaveats.cs b/src/Bws.Core/Snapshots/ComparisonCaveats.cs index 6be2462e..35f8dffa 100644 --- a/src/Bws.Core/Snapshots/ComparisonCaveats.cs +++ b/src/Bws.Core/Snapshots/ComparisonCaveats.cs @@ -35,6 +35,14 @@ namespace Bws.Core.Snapshots; /// owner's decision). Decided by the schema number, which both files always carry, so it is never /// "not known". /// +/// +/// One side holds whole triggers and the other only their kinds and actions - schema seven against an +/// older one (logic report C01, 2026-10-27). Triggers are then compared by kind and action alone on every +/// entry, so a trigger added, removed or turned from start to stop is still reported, and a change in what +/// exactly one waits for is not looked at. Decided by the schema number, so never "not known". The owner's +/// decision that day, over the alternative of not comparing triggers at all, which is how file versions +/// are treated beside six: there the older value is a different measurement, here it is a smaller one. +/// /// /// 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 @@ -49,6 +57,7 @@ public sealed record ComparisonCaveats( bool LanguageDiffers, bool AccountDiffers, bool FileVersionSourceDiffers, + bool TriggerDetailDiffers, IReadOnlyList NotKnown) { /// @@ -57,6 +66,12 @@ public sealed record ComparisonCaveats( /// internal const int OwnFileVersionSince = 6; + /// + /// The first schema whose triggers carry their subtype and data - see + /// , the paragraph on six to seven. + /// + internal const int WholeTriggersSince = 7; + /// /// The two fields a person reads in their own language, left out of every entry when the two /// managers name things in different languages (). @@ -118,6 +133,7 @@ internal static ComparisonCaveats Between(SnapshotMetadata before, SnapshotMetad Disagree(before.NamesLanguage, after.NamesLanguage, StringComparison.OrdinalIgnoreCase), !string.Equals(before.TakenBy, after.TakenBy, StringComparison.OrdinalIgnoreCase), (before.SchemaVersion >= OwnFileVersionSince) != (after.SchemaVersion >= OwnFileVersionSince), + (before.SchemaVersion >= WholeTriggersSince) != (after.SchemaVersion >= WholeTriggersSince), notKnown); } diff --git a/src/Bws.Core/Snapshots/EntryDocument.cs b/src/Bws.Core/Snapshots/EntryDocument.cs index 665db4d0..aac55201 100644 --- a/src/Bws.Core/Snapshots/EntryDocument.cs +++ b/src/Bws.Core/Snapshots/EntryDocument.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json.Serialization; namespace Bws.Core.Snapshots; @@ -12,8 +13,24 @@ namespace Bws.Core.Snapshots; /// public sealed record UnreadableDocument(int ErrorCode, string Message); -/// One condition that starts or stops the entry by itself. -public sealed record TriggerDocument(string Kind, string Action); +/// +/// One condition that starts or stops the entry by itself. +/// +/// Subtype and data since schema seven (2026-10-27, logic report C01, owner's decision): until +/// then it held the kind and the action only. The subtype is the GUID in lower case, null when the +/// manager points at none. The data is always a list in a seven, empty for the kinds that carry +/// none - and null only in a file written before seven, which never held it. That null is how a +/// comparison of a seven with an older file knows to compare kinds and actions alone +/// (). +/// +public sealed record TriggerDocument( + string Kind, string Action, string? Subtype, IReadOnlyList? Data); + +/// +/// One data item of a trigger: its type in our words and its values as text - see +/// for which form each type is written in and why. +/// +public sealed record TriggerDataDocument(string Type, IReadOnlyList Values); /// /// What the system concluded about a file's signature. @@ -339,10 +356,7 @@ public static EntryDocument From(ScmEntry entry) DependsOn = entry.DependsOn.IsPresent ? entry.DependsOn.Value : null, RequiredBy = entry.RequiredBy.IsPresent ? entry.RequiredBy.Value : null, - Triggers = entry.Triggers.IsPresent - ? [.. entry.Triggers.Value!.Select(trigger => - new TriggerDocument(trigger.Kind.ToString(), trigger.Action.ToString()))] - : null, + Triggers = entry.Triggers.IsPresent ? [.. entry.Triggers.Value!.Select(Trigger)] : null, BinaryPath = entry.BinaryPath.IsPresent ? entry.BinaryPath.Value : null, BinaryFile = entry.BinaryFile.IsPresent ? entry.BinaryFile.Value : null, @@ -377,6 +391,17 @@ public static EntryDocument From(ScmEntry entry) }; } + /// + /// One trigger as the document writes it. The GUID in lower case through the "D" form, the one + /// sc qtriggerinfo prints, so a person can put the two side by side. + /// + private static TriggerDocument Trigger(ServiceTrigger trigger) => + new( + trigger.Kind.ToString(), + trigger.Action.ToString(), + trigger.Subtype?.ToString("D", CultureInfo.InvariantCulture), + [.. trigger.Data.Select(item => new TriggerDataDocument(item.Type.ToString(), item.Values))]); + /// /// Takes the whole reading rather than its pieces. The pieces used to be handed over /// one by one, which needed a fallback for a reason that cannot be missing, and a diff --git a/src/Bws.Core/Snapshots/Snapshot.cs b/src/Bws.Core/Snapshots/Snapshot.cs index e77ae0fa..a08c2f99 100644 --- a/src/Bws.Core/Snapshots/Snapshot.cs +++ b/src/Bws.Core/Snapshots/Snapshot.cs @@ -232,8 +232,17 @@ public sealed record Snapshot(SnapshotMetadata Metadata, IReadOnlyListSIX TO SEVEN ON 2026-10-27, AND A TRIGGER IS WHOLE - logic report C01, the owner's decision + /// to do it in that series rather than leave it in the backlog. Each trigger carries its subtype and its + /// data items beside the kind and the action (), where until then a file + /// could not tell one RPC interface from another. Four to six are still read: a trigger in them has + /// neither member and reads with both as null. A seven against an older file compares triggers by kind + /// and action alone and says so once () - without + /// that, every entry with a trigger would differ by format alone, 117 of 780 on the machine this was + /// written on. And an older build refuses a seven by name rather than dropping what it cannot read. /// - public const int CurrentSchemaVersion = 6; + public const int CurrentSchemaVersion = 7; /// /// The oldest schema this build still reads. Four since 2026-09-30 - see the version five diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs b/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs index 7ade392b..0eb500a7 100644 --- a/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs +++ b/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs @@ -19,9 +19,10 @@ namespace Bws.Core.Snapshots; public sealed partial record SnapshotDiff { /// - /// Fields whose value is a list where neither the order nor the spelling carries meaning. + /// Fields whose value is a list where the order carries no meaning - and, for all but one of them, + /// neither does the spelling (). /// - /// Three fields, and each one is on this list for a written reason rather than because it + /// Four fields, and each one is on this list for a written reason rather than because it /// happens to be an array. /// /// requiredPrivileges - the manager hands these back exactly as each service declared @@ -36,8 +37,10 @@ public sealed partial record SnapshotDiff /// in whatever order the manager answered. That order is promised nowhere. This file's own /// neighbour sorts ENTRIES for precisely that reason, having seen the order move. /// - /// triggers - same argument about order. The two words inside each one are written by - /// this build rather than by the manager, so their spelling is ours and stable. + /// triggers - same argument about order. Its spelling counts since 2026-10-27 (logic + /// report C01, owner's decision "yes, exactly"): until schema seven a trigger was two words this + /// build writes, so folding their case cost nothing. Since seven it carries the manager's data, + /// and a network endpoint trigger fires on "an exact match" of it - see . /// /// requiredBy - the other direction of dependsOn, added 2026-09-06, and the same /// argument twice over: service names, compared without case, in an order the manager promises @@ -54,6 +57,52 @@ public sealed partial record SnapshotDiff /// internal static readonly string[] Unordered = ["dependsOn", "requiredBy", "requiredPrivileges", "triggers"]; + /// + /// The lists of whose items are names Windows compares without case - every + /// one of them but triggers, for the reason given there. + /// + private static readonly string[] ItemsIgnoreCase = ["dependsOn", "requiredBy", "requiredPrivileges"]; + + /// The one list whose items are narrowed when one side is older than whole triggers. + private const string Triggers = "triggers"; + + /// The two entries in the form the comparison reads them, narrowed when one side is older than whole triggers. + private static (JsonObject Was, JsonObject Now) Documents(EntryDocument before, EntryDocument after, bool kindsOnly) + { + var was = SnapshotJson.Document(before); + var now = SnapshotJson.Document(after); + + if (kindsOnly) + { + KindsAndActions(was); + KindsAndActions(now); + } + + return (was, now); + } + + /// + /// Narrows every trigger in a document to its kind and action - what a file older than schema seven + /// holds - so the two sides of compare the same + /// question. A document without triggers is left as it is. + /// + private static void KindsAndActions(JsonObject document) + { + if (document[Triggers] is not JsonArray triggers) + { + return; + } + + document[Triggers] = new JsonArray( + [ + .. triggers.Select(trigger => new JsonObject + { + ["kind"] = trigger?["kind"]?.DeepClone(), + ["action"] = trigger?["action"]?.DeepClone() + }) + ]); + } + /// /// Fields whose value names something Windows itself compares without case. /// @@ -105,12 +154,16 @@ public sealed partial record SnapshotDiff { if (node is JsonArray items && Unordered.Contains(field, StringComparer.Ordinal)) { + var caseless = ItemsIgnoreCase.Contains(field, StringComparer.Ordinal); + // The unit separator, because it is the one character none of these values can hold - - // joining on a comma would let two lists made of different pieces compare equal. + // joining on a comma would let two lists made of different pieces compare equal. A trigger + // is written as JSON here, where the separator would come out escaped, so it holds for them too. return string.Join( '\u001f', items - .Select(item => (Text(item) ?? string.Empty).ToLowerInvariant()) + .Select(item => Text(item) ?? string.Empty) + .Select(text => caseless ? text.ToLowerInvariant() : text) .OrderBy(text => text, StringComparer.Ordinal)); } diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.cs b/src/Bws.Core/Snapshots/SnapshotDiff.cs index 7e01394c..317aae4d 100644 --- a/src/Bws.Core/Snapshots/SnapshotDiff.cs +++ b/src/Bws.Core/Snapshots/SnapshotDiff.cs @@ -303,7 +303,7 @@ private static SnapshotDiff Between( { if (left.TryGetValue(entry.ServiceName, out var was)) { - var difference = Compare(was, entry, unseen, ignored); + var difference = Compare(was, entry, unseen, ignored, caveats.TriggerDetailDiffers); if (difference is not null) { @@ -385,11 +385,15 @@ private static List Kept(IReadOnlyList entries, ou /// 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. /// + /// + /// One side is older than whole triggers, so both are narrowed to what the older one holds + /// (), and a reported difference shows the two + /// narrowed values rather than one whole and one not. + /// private static ChangedEntry? Compare( - EntryDocument before, EntryDocument after, Unseen unseen, string[] ignored) + EntryDocument before, EntryDocument after, Unseen unseen, string[] ignored, bool kindsOnly) { - var was = SnapshotJson.Document(before); - var now = SnapshotJson.Document(after); + var (was, now) = Documents(before, after, kindsOnly); // Two ways a field can be missing, and the snapshot format already tells them apart. // Using that distinction here is the whole of this decision. diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index 7b16001b..d624ef4c 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -557,6 +557,7 @@ "gui.diff.caveat.language": "The service manager names things in a different language on each side, so display names and descriptions were not compared.", "gui.diff.caveat.account": "The snapshot was taken by a different account. What one account may read, the other may be refused.", "gui.diff.caveat.fileVersion": "The snapshot was written before this tool read each file's version from the file itself, so file versions were not compared.", + "gui.diff.caveat.triggers": "The snapshot was written before this tool kept what exactly each trigger waits for, so triggers were compared by kind and action only.", "gui.diff.caveat.notKnown": "One of the two sides does not record these, so they could not be checked: {0}", "gui.diff.caveat.neitherRead": "On at least one entry neither side holds these fields, so those entries were not compared on them: {0}", "gui.diff.caveat.instancesLeftOut": "Per-user session copies come and go with the people signed in, so they are not compared - in the snapshot: {0}, now: {1}.", @@ -580,6 +581,7 @@ "gui.diff.files.caveat.tool": "The two snapshots were written by different versions of this tool.", "gui.diff.files.caveat.account": "The two snapshots were taken by different accounts. What one account may read, the other may be refused.", "gui.diff.files.caveat.fileVersion": "One snapshot was written before this tool read each file's version from the file itself, so file versions were not compared.", + "gui.diff.files.caveat.triggers": "One snapshot was written before this tool kept what exactly each trigger waits for, so triggers were compared by kind and action only.", "gui.diff.files.caveat.instancesLeftOut": "Per-user session copies come and go with the people signed in, so they are not compared - in the earlier snapshot: {0}, in the later one: {1}.", "gui.diff.show": "Show: {0}", "gui.diff.show.hint": "Choose which rows the table shows. The sentences above it are never hidden.", diff --git a/src/Bws.Gui/ViewModels/DiffCaveats.cs b/src/Bws.Gui/ViewModels/DiffCaveats.cs index 9b5ccda2..7cff0f4d 100644 --- a/src/Bws.Gui/ViewModels/DiffCaveats.cs +++ b/src/Bws.Gui/ViewModels/DiffCaveats.cs @@ -10,7 +10,7 @@ namespace Bws.Gui.ViewModels; /// learning that one side was read without administrator rights has already believed something. /// The same facts the command line says, in the window's own words and its own language file. /// -/// Seven of them name the sides, and those come in two wordings since W2 - "the snapshot and +/// Eight of them name the sides, and those come in two wordings since W2 - "the snapshot and /// this window" against a machine read now, "the two snapshots" against a second file, which is the /// wording the command line has always used for two files. The other four speak of "either side" and /// are true of both. @@ -31,6 +31,7 @@ internal static IReadOnlyList Of(SnapshotDiff diff, DiffSides sides) (caveats.LanguageDiffers, () => Texts.Of("gui.diff.caveat.language")), (caveats.AccountDiffers, () => files ? Texts.Of("gui.diff.files.caveat.account") : Texts.Of("gui.diff.caveat.account")), (caveats.FileVersionSourceDiffers, () => files ? Texts.Of("gui.diff.files.caveat.fileVersion") : Texts.Of("gui.diff.caveat.fileVersion")), + (caveats.TriggerDetailDiffers, () => files ? Texts.Of("gui.diff.files.caveat.triggers") : Texts.Of("gui.diff.caveat.triggers")), (caveats.NotKnown.Count > 0, () => Texts.Of("gui.diff.caveat.notKnown", string.Join(", ", caveats.NotKnown))), (diff.NeitherRead.Count > 0, () => Texts.Of("gui.diff.caveat.neitherRead", string.Join(", ", diff.NeitherRead.Select(DiffLines.Label)))), (diff.LeftOut.Any, () => LeftOut(diff.LeftOut, files)), diff --git a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs index c6c72de9..a86ab952 100644 --- a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs +++ b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs @@ -146,6 +146,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-7.json"] = + "the kept copy of the snapshot file since schema seven, 2026-10-27 - the same specimens as " + + "the copy below with whole triggers, so the same reason: display names written as " + + "themselves, not escaped", + ["tests/Bws.Core.Tests/Golden/snapshot-schema-6.json"] = "the kept copy of the snapshot file since schema six, 2026-10-06 - the same specimens and " + "shape as the copy below with the schema number moved, so the same reason: display names " diff --git a/tests/Bws.Architecture.Tests/ShapeCeilings.cs b/tests/Bws.Architecture.Tests/ShapeCeilings.cs index 0f7928af..0e94e58d 100644 --- a/tests/Bws.Architecture.Tests/ShapeCeilings.cs +++ b/tests/Bws.Architecture.Tests/ShapeCeilings.cs @@ -34,19 +34,27 @@ internal static class ShapeCeilings internal const double NearShare = 0.7; /// - /// 68 lines of code, EntryDocument.From - the snapshot document built field by field. Refusals.Answer - /// is one behind at 67 and three more stand at 66, so the band is crowded right under the top. + /// 66 lines of code, shared by PlanText.Render, Refusals.Answer and PlanBuilder.Build, so the band is + /// crowded right under the top. 68 until 2026-10-27, when EntryDocument.From - the snapshot document + /// built field by field - handed its trigger mapping to a method of its own (logic report C01) and + /// fell to 65. /// - internal const int LongestMethod = 68; + internal const int LongestMethod = 66; /// - /// Eleven at 48 lines of code or more. Fourteen until 2026-10-05, when BinaryPathResolver.Resolve + /// Fifteen at 47 lines of code or more. Fourteen until 2026-10-05, when BinaryPathResolver.Resolve /// handed its prefix walk to a method of its own (stability report R-3) and left the crowd, and /// thirteen until later that day, when Execution.Report did the same with its timing lines (C-4). /// Twelve until the review of PR #96, when the catalogue question in WindowsBinaryInspector answered /// with one value instead of an answer and a flag beside it, and went from 50 lines to 43. + /// + /// Eleven to fifteen on 2026-10-27 by the owner's decision, and nothing grew. The ceiling went + /// down from 68 to 66 (see ), the band followed it from 48 to 47, and four + /// methods that already stood at 47 came inside it untouched: PlanJson.Render, PlanRunner.RunStep, + /// MainWindow.CarryOut and Catalogue.Make. The alternative - shortening four methods outside the + /// change that moved the line - was put to the owner and declined. /// - internal const int MethodsNearLongest = 11; + internal const int MethodsNearLongest = 15; /// /// 23 forks, EntryDocument.From. Catalogue.Make is next at 22 - measured 2026-10-05, when this diff --git a/tests/Bws.Cli.Tests/AsciiDocumentTests.cs b/tests/Bws.Cli.Tests/AsciiDocumentTests.cs index 1c9a519b..d2148b17 100644 --- a/tests/Bws.Cli.Tests/AsciiDocumentTests.cs +++ b/tests/Bws.Cli.Tests/AsciiDocumentTests.cs @@ -88,7 +88,7 @@ public void A_comparison_is_ascii_and_says_the_name_the_file_held() { var diff = new SnapshotDiff( [new EntryPresence("Aaa", Hostile)], [], [], [], [], [new RefusedField("description", [Hostile])], [], - new ComparisonCaveats(false, false, false, false, false, false, false, []), + new ComparisonCaveats(false, false, false, false, false, false, false, false, []), new InstancesLeftOut(0, 0), [new EntryPresence("Ddd", Hostile)]); diff --git a/tests/Bws.Cli.Tests/DiffOutputTests.cs b/tests/Bws.Cli.Tests/DiffOutputTests.cs index 4ba9b9d1..975a8e3b 100644 --- a/tests/Bws.Cli.Tests/DiffOutputTests.cs +++ b/tests/Bws.Cli.Tests/DiffOutputTests.cs @@ -57,7 +57,7 @@ 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, false, ["operatingSystemVersion", "namesLanguage"]) + Caveats = new ComparisonCaveats(false, false, false, false, true, true, false, false, ["operatingSystemVersion", "namesLanguage"]) }; var text = DiffText.Render(diff); @@ -80,16 +80,39 @@ public void The_caveat_of_schema_six_is_said_and_written_last() { var diff = Of(changed: []) with { - Caveats = new ComparisonCaveats(false, false, false, false, false, false, true, []) + Caveats = new ComparisonCaveats(false, false, false, false, false, false, true, false, []) }; Assert.Contains(Texts.Of("cli.diff.caveat.fileVersion"), DiffText.Render(diff), StringComparison.Ordinal); - // Last, so a script reading the document by position before 2026-10-06 still finds what it found. + // After everything that was there before it, so a script reading the document by position before + // 2026-10-06 still finds what it found - and followed only by the caveat schema seven added. var caveats = Document(diff).GetProperty("caveats"); Assert.True(caveats.GetProperty("fileVersionSourceDiffers").GetBoolean()); - Assert.Equal("fileVersionSourceDiffers", caveats.EnumerateObject().Last().Name); + Assert.Equal( + ["fileVersionSourceDiffers", "triggerDetailDiffers"], + caveats.EnumerateObject().TakeLast(2).Select(field => field.Name)); + } + + /// + /// The caveat of schema seven (logic report C01, 2026-10-27): said in the text and written last in + /// the document, so nothing a script read by position before that day moved. + /// + [Fact] + public void The_caveat_of_schema_seven_is_said_and_written_last() + { + var diff = Of(changed: []) with + { + Caveats = new ComparisonCaveats(false, false, false, false, false, false, false, true, []) + }; + + Assert.Contains(Texts.Of("cli.diff.caveat.triggers"), DiffText.Render(diff), StringComparison.Ordinal); + + var caveats = Document(diff).GetProperty("caveats"); + + Assert.True(caveats.GetProperty("triggerDetailDiffers").GetBoolean()); + Assert.Equal("triggerDetailDiffers", caveats.EnumerateObject().Last().Name); } [Fact] @@ -182,7 +205,7 @@ public void A_field_refused_to_both_snapshots_is_written_last_with_its_entries() private static SnapshotDiff Of(IReadOnlyList changed, InstancesLeftOut? leftOut = null) => new( [], [], changed, [], [], [], [], - new ComparisonCaveats(false, false, false, false, false, false, false, []), + new ComparisonCaveats(false, false, false, false, false, false, false, false, []), leftOut ?? new InstancesLeftOut(0, 0), []); diff --git a/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs b/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs index e3e88be3..eb8ca3ff 100644 --- a/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs +++ b/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs @@ -65,7 +65,7 @@ public void The_group_of_every_difference_is_a_member_spelled_as_its_enumeration var diff = new SnapshotDiff( [], [], [new ChangedEntry("Spooler", "Print Spooler", differences, [])], [], [], [], [], - new ComparisonCaveats(false, false, false, false, false, false, false, []), + new ComparisonCaveats(false, false, false, false, false, false, false, false, []), new InstancesLeftOut(0, 0), []); diff --git a/tests/Bws.Cli.Tests/TerminalTextTests.cs b/tests/Bws.Cli.Tests/TerminalTextTests.cs index 9336aab6..e71912b2 100644 --- a/tests/Bws.Cli.Tests/TerminalTextTests.cs +++ b/tests/Bws.Cli.Tests/TerminalTextTests.cs @@ -88,7 +88,7 @@ public void A_comparison_of_somebody_else_s_file_moves_nothing() [new EntryPresence("Aaa", Hostile)], [], [new ChangedEntry("Bbb", Hostile, [new FieldDifference("account", DifferenceGroup.Configuration, Hostile, "LocalSystem")], [])], [], [Hostile], [new RefusedField(Hostile, [Hostile])], [new EntryPresence("Ccc", Hostile)], - new ComparisonCaveats(false, false, false, false, false, false, false, []), + new ComparisonCaveats(false, false, false, false, false, false, false, false, []), new InstancesLeftOut(0, 0), [new EntryPresence("Ddd", Hostile)]); diff --git a/tests/Bws.Core.Tests/Bws.Core.Tests.csproj b/tests/Bws.Core.Tests/Bws.Core.Tests.csproj index 8c8469ac..20ba78db 100644 --- a/tests/Bws.Core.Tests/Bws.Core.Tests.csproj +++ b/tests/Bws.Core.Tests/Bws.Core.Tests.csproj @@ -52,6 +52,7 @@ + diff --git a/tests/Bws.Core.Tests/DocumentValueGuards.cs b/tests/Bws.Core.Tests/DocumentValueGuards.cs index b7f1b274..abccbc1d 100644 --- a/tests/Bws.Core.Tests/DocumentValueGuards.cs +++ b/tests/Bws.Core.Tests/DocumentValueGuards.cs @@ -54,15 +54,7 @@ public void Every_enumerated_value_is_a_member_spelled_the_way_the_enumeration_s checkedValues += Member(signature, "status"); } - if (document.TryGetProperty("triggers", out var triggers) - && triggers.ValueKind == JsonValueKind.Array) - { - foreach (var trigger in triggers.EnumerateArray()) - { - checkedValues += Member(trigger, "kind"); - checkedValues += Member(trigger, "action"); - } - } + checkedValues += Triggers(document); } // A GUARD THAT READ NOTHING PASSES, which is how this whole family goes quietly wrong - @@ -127,6 +119,33 @@ public void Every_list_in_the_document_is_either_unordered_or_named_as_ordered() + $"[{string.Join(", ", decided)}]."); } + /// + /// The enumerated values of one entry's triggers: kind and action, and since schema seven + /// (2026-10-27) the type of each data item - the one a script reads to know how to read the values + /// beside it. + /// + private static int Triggers(JsonElement document) + { + if (!document.TryGetProperty("triggers", out var triggers) || triggers.ValueKind != JsonValueKind.Array) + { + return 0; + } + + var checkedValues = 0; + + foreach (var trigger in triggers.EnumerateArray()) + { + checkedValues += Member(trigger, "kind") + Member(trigger, "action"); + + if (trigger.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array) + { + checkedValues += data.EnumerateArray().Sum(item => Member(item, "type")); + } + } + + return checkedValues; + } + /// /// Arrays that name what could not be read rather than being values in their own right. /// diff --git a/tests/Bws.Core.Tests/Fakes/Specimens.cs b/tests/Bws.Core.Tests/Fakes/Specimens.cs index b289ac99..69ca84e7 100644 --- a/tests/Bws.Core.Tests/Fakes/Specimens.cs +++ b/tests/Bws.Core.Tests/Fakes/Specimens.cs @@ -312,10 +312,31 @@ private static ScmEntry AsADriver(ScmEntry entry) => entry with // this entry from "automatic and did not start" into "waiting to be asked for", and // that difference is the whole reason triggers are read at all. One of them is the // kind the interop metadata has no name for. + // + // Whole since schema seven (2026-10-27). The subtypes are the real ones - the ETW provider + // sc qtriggerinfo names for this service, and the GUID every system state trigger on that + // machine carries - and so are the eight bytes of the second. THE DATA ON THE FIRST IS NOT: + // the real one carries none. It is here so the kept copy of the file holds every type of + // data item in one place - a run of strings shaped like the two firewall triggers, one with + // an empty string between two others, a level and both keyword masks. Triggers = Reading>.Present( [ - new ServiceTrigger(TriggerKind.Custom, TriggerAction.Start), - new ServiceTrigger(TriggerKind.CustomSystemStateChange, TriggerAction.Start) + new ServiceTrigger( + TriggerKind.Custom, + TriggerAction.Start, + new Guid("f5528ada-be5f-4f14-8aef-a95de7281161"), + [ + new TriggerData(TriggerDataType.String, ["500", "UDP", @"%windir%\system32\svchost.exe", "IKEEXT"]), + new TriggerData(TriggerDataType.String, ["first", "", "third"]), + new TriggerData(TriggerDataType.Level, ["4"]), + new TriggerData(TriggerDataType.KeywordAny, ["0x0000000000000001"]), + new TriggerData(TriggerDataType.KeywordAll, ["0x8000000000000010"]) + ]), + new ServiceTrigger( + TriggerKind.CustomSystemStateChange, + TriggerAction.Start, + new Guid("2d7a2816-0c5e-45fc-9ce7-570e5ecde9c9"), + [new TriggerData(TriggerDataType.Binary, ["7528bca32e07c641"])]) ]) }; @@ -434,6 +455,10 @@ private static ScmEntry AsADriver(ScmEntry entry) => entry with /// /// Read from a real machine: this entry carries six network endpoint triggers, /// all the same kind and all the same action, and sc qtriggerinfo lists six. + /// + /// Whole since schema seven, with the RPC interfaces read on 2026-10-27 - and one of the six + /// is written in upper case by the manager while the other five are not, which is why trigger data + /// is compared exactly. They are six different triggers, and before seven a file could not say so. /// internal static ScmEntry ManyTriggersOfOneKind => Entries.Any with { @@ -443,9 +468,30 @@ private static ScmEntry AsADriver(ScmEntry entry) => entry with StartType = Reading.Present(Core.StartType.Manual), DelayedAuto = Reading.Absent(), Triggers = Reading>.Present( - [.. Enumerable.Repeat(new ServiceTrigger(TriggerKind.NetworkEndpoint, TriggerAction.Start), 6)]) + [ + .. new[] + { + "201ef99a-7fa0-444c-9399-19ba84f12a1a", + "5f54ce7d-5b79-4175-8584-cb65313a0e98", + "fd7a0523-dc70-43dd-9b2e-9c5ed48225b1", + "58e604e8-9adb-4d2e-a464-3b0683fb1480", + "0497B57D-2E66-424F-A0C6-157CD5D41700", + "0f738e20-73c0-4ca8-aa6a-8dfef545fea8" + }.Select(rpcInterface => new ServiceTrigger( + TriggerKind.NetworkEndpoint, + TriggerAction.Start, + RpcInterfaceEvent, + [new TriggerData(TriggerDataType.String, [rpcInterface])])) + ]) }; + /// + /// RPC_INTERFACE_EVENT_GUID: the subtype saying a network endpoint trigger waits for an RPC interface + /// rather than a named pipe. From the documentation of SERVICE_TRIGGER, and the one sc qtriggerinfo + /// prints beside every such trigger. + /// + internal static Guid RpcInterfaceEvent => new("bc90d167-9470-4139-a9ba-be0bbbf5b74d"); + /// /// Triggers nobody asked for yet. The fourth state, and the reason ADR-13 exists. /// diff --git a/tests/Bws.Core.Tests/Golden/snapshot-schema-7.json b/tests/Bws.Core.Tests/Golden/snapshot-schema-7.json new file mode 100644 index 00000000..c2db0542 --- /dev/null +++ b/tests/Bws.Core.Tests/Golden/snapshot-schema-7.json @@ -0,0 +1,1410 @@ +{ + "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", + "data": [ + { + "type": "String", + "values": [ + "201ef99a-7fa0-444c-9399-19ba84f12a1a" + ] + } + ], + "kind": "NetworkEndpoint", + "subtype": "bc90d167-9470-4139-a9ba-be0bbbf5b74d" + }, + { + "action": "Start", + "data": [ + { + "type": "String", + "values": [ + "5f54ce7d-5b79-4175-8584-cb65313a0e98" + ] + } + ], + "kind": "NetworkEndpoint", + "subtype": "bc90d167-9470-4139-a9ba-be0bbbf5b74d" + }, + { + "action": "Start", + "data": [ + { + "type": "String", + "values": [ + "fd7a0523-dc70-43dd-9b2e-9c5ed48225b1" + ] + } + ], + "kind": "NetworkEndpoint", + "subtype": "bc90d167-9470-4139-a9ba-be0bbbf5b74d" + }, + { + "action": "Start", + "data": [ + { + "type": "String", + "values": [ + "58e604e8-9adb-4d2e-a464-3b0683fb1480" + ] + } + ], + "kind": "NetworkEndpoint", + "subtype": "bc90d167-9470-4139-a9ba-be0bbbf5b74d" + }, + { + "action": "Start", + "data": [ + { + "type": "String", + "values": [ + "0497B57D-2E66-424F-A0C6-157CD5D41700" + ] + } + ], + "kind": "NetworkEndpoint", + "subtype": "bc90d167-9470-4139-a9ba-be0bbbf5b74d" + }, + { + "action": "Start", + "data": [ + { + "type": "String", + "values": [ + "0f738e20-73c0-4ca8-aa6a-8dfef545fea8" + ] + } + ], + "kind": "NetworkEndpoint", + "subtype": "bc90d167-9470-4139-a9ba-be0bbbf5b74d" + } + ] + }, + { + "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", + "data": [ + { + "type": "String", + "values": [ + "500", + "UDP", + "%windir%\\system32\\svchost.exe", + "IKEEXT" + ] + }, + { + "type": "String", + "values": [ + "first", + "", + "third" + ] + }, + { + "type": "Level", + "values": [ + "4" + ] + }, + { + "type": "KeywordAny", + "values": [ + "0x0000000000000001" + ] + }, + { + "type": "KeywordAll", + "values": [ + "0x8000000000000010" + ] + } + ], + "kind": "Custom", + "subtype": "f5528ada-be5f-4f14-8aef-a95de7281161" + }, + { + "action": "Start", + "data": [ + { + "type": "Binary", + "values": [ + "7528bca32e07c641" + ] + } + ], + "kind": "CustomSystemStateChange", + "subtype": "2d7a2816-0c5e-45fc-9ce7-570e5ecde9c9" + } + ] + }, + { + "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": 7, + "takenAt": "2026-09-29T12:00:00+02:00", + "takenBy": "EXAMPLE\\operator", + "tool": "0.3.0" + } +} diff --git a/tests/Bws.Core.Tests/SnapshotGoldenTests.cs b/tests/Bws.Core.Tests/SnapshotGoldenTests.cs index ff049982..208b3cd1 100644 --- a/tests/Bws.Core.Tests/SnapshotGoldenTests.cs +++ b/tests/Bws.Core.Tests/SnapshotGoldenTests.cs @@ -32,7 +32,13 @@ namespace Bws.Core.Tests; /// public sealed class SnapshotGoldenTests { - private const string Kept = "snapshot-schema-6.json"; + private const string Kept = "snapshot-schema-7.json"; + + /// + /// The copy kept before schema seven, 2026-10-27 - the same entries, with every trigger holding its + /// kind and action only. Still read, and compared with a seven by those two alone. + /// + private const string KeptSix = "snapshot-schema-6.json"; /// /// The copy kept before schema six, 2026-10-06 - the same shape and the same entries, with file @@ -59,7 +65,7 @@ public void The_file_is_written_byte_for_byte_as_the_kept_copy() return; } - var actual = Path.Combine(AppContext.BaseDirectory, "snapshot-schema-6.actual.json"); + var actual = Path.Combine(AppContext.BaseDirectory, "snapshot-schema-7.actual.json"); File.WriteAllText(actual, written, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); Assert.Fail( @@ -86,7 +92,7 @@ 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 == 6, + Snapshot.CurrentSchemaVersion == 7, $"The schema is now {Snapshot.CurrentSchemaVersion}. Keep a new copy named for it beside {Kept}."); } @@ -151,6 +157,74 @@ public void A_version_five_file_is_read_and_its_file_versions_are_not_compared() Assert.True(plain.Drifted); } + /// + /// A file written before schema seven still reads, and its triggers are compared with a seven by kind + /// and action alone, said once - logic report C01, the owner's decision of 2026-10-27. The two kept + /// copies hold the same entries, so nothing may differ: without the caveat, every entry with a trigger + /// would differ by format alone. + /// + [Fact] + public void A_version_six_file_is_read_and_its_triggers_are_compared_by_kind_and_action() + { + Assert.True(SnapshotJson.TryRead(KeptText(KeptSix), out var older, out var failure), failure); + Assert.True(SnapshotJson.TryRead(KeptText(Kept), out var newer, out failure), failure); + Assert.True(SnapshotDiff.TryBetween(older!, newer!, out var diff, out failure), failure); + + Assert.True(diff.Caveats.TriggerDetailDiffers); + Assert.False(diff.Drifted); + Assert.Empty(diff.Changed); + + // The canary: the kind is still compared under the caveat, so one trigger turned into another + // kind in the older file IS a difference - the lines above are about the narrowing, not about + // triggers going unread. + var moved = Once(KeptText(KeptSix), "\"kind\": \"CustomSystemStateChange\"", "\"kind\": \"DeviceArrival\""); + + Assert.True(SnapshotJson.TryRead(moved, out var olderMoved, out failure), failure); + Assert.True(SnapshotDiff.TryBetween(olderMoved!, newer!, out var kinds, out failure), failure); + Assert.True(kinds.Drifted); + Assert.Equal("sppsvc", Assert.Single(kinds.Changed).ServiceName); + } + + /// + /// Between two sevens a trigger's data is compared exactly, the owner's decision of 2026-10-27: + /// one RPC interface the manager writes in upper case, written in lower case, is a difference - a + /// network endpoint trigger fires on an exact match. And the same edit in a file labelled six is not, + /// because against an older file only the kind and the action are looked at. + /// + [Fact] + public void A_change_of_case_in_trigger_data_is_a_difference_between_two_sevens_only() + { + Assert.True(SnapshotJson.TryRead(KeptText(Kept), out var newer, out var failure), failure); + + var lowered = Once(KeptText(Kept), "0497B57D-2E66-424F-A0C6-157CD5D41700", "0497b57d-2e66-424f-a0c6-157cd5d41700"); + + Assert.True(SnapshotJson.TryRead(lowered, out var seven, out failure), failure); + Assert.True(SnapshotDiff.TryBetween(seven!, newer!, out var exact, out failure), failure); + + var changed = Assert.Single(exact.Changed); + + Assert.Equal("Appinfo", changed.ServiceName); + Assert.Equal("triggers", Assert.Single(changed.Differences).Field); + Assert.False(exact.Caveats.TriggerDetailDiffers); + + var labelledSix = Once(lowered, "\"schemaVersion\": 7", "\"schemaVersion\": 6"); + + Assert.True(SnapshotJson.TryRead(labelledSix, out var six, out failure), failure); + Assert.True(SnapshotDiff.TryBetween(six!, newer!, out var narrowed, out failure), failure); + Assert.True(narrowed.Caveats.TriggerDetailDiffers); + Assert.Empty(narrowed.Changed); + } + + /// The text with one occurrence of a piece replaced, failing when the piece is not there. + private static string Once(string text, string piece, string replacement) + { + var at = text.IndexOf(piece, StringComparison.Ordinal); + + Assert.True(at >= 0, $"The kept copy holds no {piece}."); + + return string.Concat(text.AsSpan(0, at), replacement, text.AsSpan(at + piece.Length)); + } + private static string MovedVersion(string text) { const string field = "\"fileVersion\": \""; @@ -175,7 +249,7 @@ [.. Specimens.Inspected.Where(entry => entry.ServiceName != Specimens.CaseOnlyDi { Metadata = new SnapshotMetadata { - SchemaVersion = 6, + SchemaVersion = 7, Machine = "GOLDEN", OperatingSystem = "Microsoft Windows NT 10.0.26100.0", TakenAt = new DateTimeOffset(2026, 9, 29, 12, 0, 0, TimeSpan.FromHours(2)), diff --git a/tests/Bws.Core.Tests/TriggerBlockTests.cs b/tests/Bws.Core.Tests/TriggerBlockTests.cs new file mode 100644 index 00000000..b5c168b9 --- /dev/null +++ b/tests/Bws.Core.Tests/TriggerBlockTests.cs @@ -0,0 +1,223 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; + +namespace Bws.Core.Tests; + +/// +/// What a trigger record points at - its subtype and its data items - handed over as a block. +/// +/// Written 2026-10-27 for logic report C01, when the walk went from one array to three levels +/// of pointers into one block. Each level is a count or a pointer the manager wrote, so each gets the +/// question asks of the first: what happens when it is wrong. The answer +/// differs from the record count's on purpose - a data item outside the block refuses the field rather +/// than being dropped, because a trigger missing one of its items waits for something else. +/// +public sealed class TriggerBlockTests +{ + /// SERVICE_TRIGGER_INFO on a 64 bit machine: the count at 0, the records pointer at 8. + private const int HeaderLength = 24; + + /// + /// SERVICE_TRIGGER: the kind at 0, the action at 4, the subtype pointer at 8, the item count at 16 + /// and the items pointer at 24 - thirty two bytes. + /// + private const int RecordLength = 32; + + private const int SubtypePointer = 8; + private const int ItemCount = 16; + private const int ItemsPointer = 24; + + /// SERVICE_TRIGGER_SPECIFIC_DATA_ITEM: the type at 0, the size at 4, the data pointer at 8. + private const int ItemLength = 16; + + private const int GuidLength = 16; + + private const uint Binary = 1; + private const uint Text = 2; + private const uint Level = 3; + private const uint KeywordAny = 4; + private const uint KeywordAll = 5; + + private static readonly Guid Provider = new("f5528ada-be5f-4f14-8aef-a95de7281161"); + + [Fact] + public void A_trigger_is_read_whole_with_its_subtype_and_every_type_of_data() + { + var reading = Walked( + Provider, + [ + (Binary, [0x75, 0x28, 0xBC]), + (Text, Encoding.Unicode.GetBytes("500\0UDP\0\0")), + (Level, [4]), + (KeywordAny, Little(1)), + (KeywordAll, Little(0x8000000000000010)), + (9, [0xAB]) + ]); + + var trigger = Assert.Single(reading.Value!); + + Assert.Equal(TriggerKind.Custom, trigger.Kind); + Assert.Equal(Provider, trigger.Subtype); + + Assert.Equal( + [ + (TriggerDataType.Binary, "7528bc"), + (TriggerDataType.String, "500|UDP"), + (TriggerDataType.Level, "4"), + (TriggerDataType.KeywordAny, "0x0000000000000001"), + (TriggerDataType.KeywordAll, "0x8000000000000010"), + (TriggerDataType.Unknown, "ab") + ], + trigger.Data.Select(item => (item.Type, string.Join('|', item.Values)))); + } + + /// + /// The run has its own length, so an empty string in the middle is something the manager holds and + /// is kept, and an item with no text at all still has one value to read. + /// + [Fact] + public void A_string_item_keeps_an_empty_string_inside_it_and_always_has_a_value() + { + var reading = Walked(Provider, [(Text, Encoding.Unicode.GetBytes("a\0\0b\0\0")), (Text, [])]); + + var data = Assert.Single(reading.Value!).Data; + + Assert.Equal(["a", "", "b"], data[0].Values); + Assert.Equal([""], data[1].Values); + } + + [Fact] + public void A_trigger_pointing_at_no_subtype_and_no_data_is_read_as_such() + { + var trigger = Assert.Single(Walked(subtype: null, []).Value!); + + Assert.Null(trigger.Subtype); + Assert.Empty(trigger.Data); + } + + [Fact] + public void A_subtype_pointing_outside_the_block_refuses_the_field() + { + var reading = Walked(Provider, [], (buffer, address) => + BitConverter.TryWriteBytes(buffer.AsSpan(HeaderLength + SubtypePointer), address - 4096)); + + Refused(reading); + } + + /// + /// Eight bytes declared, two of them inside the block. Reading the six beyond would be a read past + /// the end of a managed array on an elevated process. + /// + [Fact] + public void A_data_item_running_past_the_end_of_the_block_refuses_the_field() + { + var reading = Walked(Provider, [(Binary, [1, 2, 3, 4, 5, 6, 7, 8])], (buffer, address) => + BitConverter.TryWriteBytes(buffer.AsSpan(ItemsAt + 8), address + buffer.Length - 2)); + + Refused(reading); + } + + [Fact] + public void A_count_of_data_items_larger_than_the_block_refuses_the_field() + { + var reading = Walked(Provider, [(Level, [4])], (buffer, _) => + BitConverter.TryWriteBytes(buffer.AsSpan(HeaderLength + ItemCount), 1000u)); + + Refused(reading); + } + + /// + /// A level is one byte, a keyword eight and a string whole characters - anything else is a block that + /// does not describe itself, said as a refusal rather than read as far as it happens to go. + /// + [Theory] + [InlineData(Level, 2)] + [InlineData(KeywordAny, 4)] + [InlineData(KeywordAll, 9)] + [InlineData(Text, 3)] + public void A_data_item_of_a_size_its_type_does_not_allow_refuses_the_field(uint type, int length) + { + Refused(Walked(Provider, [(type, new byte[length])])); + } + + private static void Refused(Reading> reading) + { + Assert.Equal(ReadOutcome.Denied, reading.Outcome); + Assert.Equal(13, reading.ErrorCode); + } + + /// Where the items array starts in a block builds. + private const int ItemsAt = HeaderLength + RecordLength + GuidLength; + + private static byte[] Little(ulong value) + { + var bytes = new byte[sizeof(ulong)]; + + BinaryPrimitives.WriteUInt64LittleEndian(bytes, value); + + return bytes; + } + + /// + /// One custom start trigger laid out the way the manager lays it out - the header, the record, the + /// subtype, the items and then their bytes, every pointer into the same pinned block - with a chance + /// to spoil the block before it is read. + /// + private static Reading> Walked( + Guid? subtype, (uint Type, byte[] Bytes)[] items, Action? spoil = null) + { + var dataAt = ItemsAt + items.Length * ItemLength; + var buffer = new byte[dataAt + items.Sum(item => item.Bytes.Length)]; + var pinned = GCHandle.Alloc(buffer, GCHandleType.Pinned); + + try + { + var address = pinned.AddrOfPinnedObject().ToInt64(); + + BitConverter.TryWriteBytes(buffer.AsSpan(0), 1u); + BitConverter.TryWriteBytes(buffer.AsSpan(8), address + HeaderLength); + + // A custom trigger that starts the entry: kind twenty, action one. + BitConverter.TryWriteBytes(buffer.AsSpan(HeaderLength), 20u); + BitConverter.TryWriteBytes(buffer.AsSpan(HeaderLength + 4), 1u); + + if (subtype is { } guid) + { + guid.TryWriteBytes(buffer.AsSpan(HeaderLength + RecordLength)); + BitConverter.TryWriteBytes(buffer.AsSpan(HeaderLength + SubtypePointer), address + HeaderLength + RecordLength); + } + + BitConverter.TryWriteBytes(buffer.AsSpan(HeaderLength + ItemCount), (uint)items.Length); + + if (items.Length > 0) + { + BitConverter.TryWriteBytes(buffer.AsSpan(HeaderLength + ItemsPointer), address + ItemsAt); + } + + for (var index = 0; index < items.Length; index++) + { + var at = ItemsAt + index * ItemLength; + var (type, bytes) = items[index]; + + BitConverter.TryWriteBytes(buffer.AsSpan(at), type); + BitConverter.TryWriteBytes(buffer.AsSpan(at + 4), (uint)bytes.Length); + + if (bytes.Length > 0) + { + BitConverter.TryWriteBytes(buffer.AsSpan(at + 8), address + dataAt); + bytes.CopyTo(buffer, dataAt); + dataAt += bytes.Length; + } + } + + spoil?.Invoke(buffer, address); + + return ManagerBlocks.ReadTriggerBuffer(buffer); + } + finally + { + pinned.Free(); + } + } +} diff --git a/tests/Bws.Gui.Tests/TwoSnapshotsTests.cs b/tests/Bws.Gui.Tests/TwoSnapshotsTests.cs index 08941a5b..5a17b28a 100644 --- a/tests/Bws.Gui.Tests/TwoSnapshotsTests.cs +++ b/tests/Bws.Gui.Tests/TwoSnapshotsTests.cs @@ -48,6 +48,23 @@ public void Two_snapshots_are_said_as_two_snapshots_and_never_as_a_snapshot_and_ Assert.Equal(Texts.Of("gui.diff.files.row.added"), Assert.Single(sheet.Lines).What); } + /// + /// A snapshot written before whole triggers beside one written now: the sheet says once, in the + /// two-files wording, that triggers were compared by kind and action only (logic report C01, + /// 2026-10-27). + /// + [Fact] + public void An_older_snapshot_says_its_triggers_were_compared_by_kind_and_action_only() + { + var older = TwoFiles.Of(new Side(), Rows.Entry("Spooler")); + var sheet = TwoFiles.Sheet( + older with { Metadata = older.Metadata with { SchemaVersion = 6 } }, + TwoFiles.Of(new Side { Hour = 1 }, Rows.Entry("Spooler"))); + + Assert.Contains(Texts.Of("gui.diff.files.caveat.triggers"), sheet.Caveats); + Assert.DoesNotContain(Texts.Of("gui.diff.caveat.triggers"), sheet.Caveats); + } + [Fact] public void Both_notes_are_shown_and_both_sides_say_their_rights() { diff --git a/tests/Bws.Integration.Tests/SnapshotContractTests.cs b/tests/Bws.Integration.Tests/SnapshotContractTests.cs index 4d9dc43d..062a1654 100644 --- a/tests/Bws.Integration.Tests/SnapshotContractTests.cs +++ b/tests/Bws.Integration.Tests/SnapshotContractTests.cs @@ -121,7 +121,10 @@ public void The_file_says_what_it_needs_to_be_compared_later() // SIX SINCE 2026-10-06, package SB: nothing in the shape changed, the source of fileVersion did - // the file's own resource instead of a language file beside it. A five against a six does not // compare file versions and says so once, which is the whole reason the number moved. - Assert.Equal(6, metadata.GetProperty("schemaVersion").GetInt32()); + // + // SEVEN SINCE 2026-10-27, logic report C01: every trigger carries its subtype and its data. A six + // against a seven compares triggers by kind and action only and says so once. + Assert.Equal(7, metadata.GetProperty("schemaVersion").GetInt32()); Assert.StartsWith( $"{Environment.OSVersion.Version.Major}.{Environment.OSVersion.Version.Minor}.{Environment.OSVersion.Version.Build}.", metadata.GetProperty("operatingSystemVersion").GetString()!, diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index 30366882..0d593aef 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -5934,7 +5934,8 @@ $mutations = @( # The pointer half of the bound. Without it the walk follows whatever the block claims, # and a block claiming nothing at address zero is where the program ends. Name = 'blocks: a trigger pointer that lands nowhere is not followed' - File = 'src\Bws.Core\ManagerBlocks.cs' + # MOVED 2026-10-27 with the whole trigger walk, into a file of its own (logic report C01). + File = 'src\Bws.Core\ManagerBlocks.Triggers.cs' From = 'if (info.pTriggers is null || offset < 0 || offset >= buffer.Length)' To = 'if ((info.pTriggers is null || offset < 0 || offset >= buffer.Length) && false)' Test = 'ManagerBlockTests.A_count_with_a_pointer_to_nowhere_is_refused_rather_than_followed' @@ -5943,7 +5944,8 @@ $mutations = @( # The count half. Max rather than a plain removal, so that `room` is still used and the # tree still compiles - the "mutate a value, not a proof" advice from the header. Name = 'blocks: a trigger count is held down to what the block holds' - File = 'src\Bws.Core\ManagerBlocks.cs' + # MOVED 2026-10-27 with the whole trigger walk, into a file of its own (logic report C01). + File = 'src\Bws.Core\ManagerBlocks.Triggers.cs' From = 'var count = Math.Min(info.cTriggers, room);' To = 'var count = Math.Max(info.cTriggers, room);' Test = 'ManagerBlockTests.A_count_larger_than_the_block_is_held_down_to_what_the_block_holds' @@ -6245,8 +6247,9 @@ $mutations = @( File = 'src\Bws.Core\Snapshots\Snapshot.cs' # RE-ANCHORED 2026-09-30: schema five, stability report D-5, package E. # RE-ANCHORED 2026-10-06: schema six, package SB - the source of fileVersion. - From = 'public const int CurrentSchemaVersion = 6;' - To = 'public const int CurrentSchemaVersion = 7;' + # RE-ANCHORED 2026-10-27: schema seven, logic report C01 - whole triggers. + From = 'public const int CurrentSchemaVersion = 7;' + To = 'public const int CurrentSchemaVersion = 8;' Test = 'SnapshotContractTests.The_file_says_what_it_needs_to_be_compared_later' }, @{ @@ -14774,6 +14777,63 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = ' private static TimeSpan _spent;' Test = 'QueryBudgetTests.Every_walk_begins_with_the_whole_budget' } + @{ + # C01: a data item is read only when every byte of it is inside the block. The mutant checks + # where it starts and not how long it is, and reads six bytes past a pinned test array. + Name = 'triggers: a data item running past the block refuses the field' + File = 'src\Bws.Core\ManagerBlocks.Triggers.cs' + From = 'return offset >= 0 && offset <= length && size <= length - offset ? offset : null;' + To = 'return offset >= 0 && offset <= length ? offset : null;' + Test = 'TriggerBlockTests.A_data_item_running_past_the_end_of_the_block_refuses_the_field' + } + @{ + # C01: bytes in lower case hexadecimal - the comparison folds nothing in a trigger since seven. + Name = 'triggers: bytes are written in lower case' + File = 'src\Bws.Core\ManagerBlocks.Triggers.cs' + From = 'new TriggerData(TriggerDataType.Binary, [Convert.ToHexStringLower(bytes)]),' + To = 'new TriggerData(TriggerDataType.Binary, [Convert.ToHexString(bytes)]),' + Test = 'TriggerBlockTests.A_trigger_is_read_whole_with_its_subtype_and_every_type_of_data' + } + @{ + # C01: the nulls closing a run of strings are not values. + Name = 'triggers: the nulls that close a run of strings are taken off' + File = 'src\Bws.Core\ManagerBlocks.Triggers.cs' + From = 'while (kept > 1 && values[kept - 1].Length == 0)' + To = 'while (kept > values.Length)' + Test = 'TriggerBlockTests.A_trigger_is_read_whole_with_its_subtype_and_every_type_of_data' + } + @{ + # C01: a keyword is eight bytes exactly - nine is a block that does not describe itself. + Name = 'triggers: a keyword of any size but eight refuses the field' + File = 'src\Bws.Core\ManagerBlocks.Triggers.cs' + From = 'bytes.Length == sizeof(ulong)' + To = 'bytes.Length >= sizeof(ulong)' + Test = 'TriggerBlockTests.A_data_item_of_a_size_its_type_does_not_allow_refuses_the_field' + } + @{ + # C01, owner's decision "yes, exactly": trigger data is compared with its case. + Name = 'diff: trigger data is compared with its case' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' + From = 'private static readonly string[] ItemsIgnoreCase = ["dependsOn", "requiredBy", "requiredPrivileges"];' + To = 'private static readonly string[] ItemsIgnoreCase = ["dependsOn", "requiredBy", "requiredPrivileges", "triggers"];' + Test = 'SnapshotGoldenTests.A_change_of_case_in_trigger_data_is_a_difference_between_two_sevens_only' + } + @{ + # C01: beside an older file both sides are narrowed to kind and action. + Name = 'diff: a seven beside an older file compares triggers by kind and action' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' + From = 'if (kindsOnly)' + To = 'if (kindsOnly && false)' + Test = 'SnapshotGoldenTests.A_version_six_file_is_read_and_its_triggers_are_compared_by_kind_and_action' + } + @{ + # C01: the caveat comes from the two schema numbers, which every file carries. + Name = 'diff: the trigger caveat is decided by the schema numbers' + File = 'src\Bws.Core\Snapshots\ComparisonCaveats.cs' + From = '(before.SchemaVersion >= WholeTriggersSince) != (after.SchemaVersion >= WholeTriggersSince),' + To = 'false,' + Test = 'SnapshotGoldenTests.A_version_six_file_is_read_and_its_triggers_are_compared_by_kind_and_action' + } ) $mutations From d6322b82a5822f12e413f8d179dc5e90f8d26f46 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 27 Oct 2026 23:00:32 +0100 Subject: [PATCH 2/3] Answer the review of whole triggers and pin the schema range A version 7 snapshot whose trigger has no data list, or a data item without a type or a value, is refused when it is read. A version 7 file always writes them, so two files missing them would compare as equal on what neither holds. Older files never wrote the data and read as before. A trigger and its data items are values again: equal by what they hold, the data item by item and the strings with their case, rather than by the list object. Until schema 7 a trigger held two enumeration values and was equal by them. The comment on the trigger document now says that the two schema numbers decide whether triggers are compared by kind and action, not a missing data list. The guard on enumerated values fails when a trigger is written without its data list or a data item without its type, where it used to count the absence as nothing checked. The test pinning the range of schemas this build reads still named 4 to 6 and failed in CI. It now names 4 to 7 and refuses 8. Co-Authored-By: Claude Opus 5.5 --- src/Bws.Core/ServiceTrigger.cs | 55 ++++++++++++++++++- src/Bws.Core/Snapshots/EntryDocument.cs | 7 ++- src/Bws.Core/Snapshots/Snapshot.cs | 32 ++++++++++- tests/Bws.Core.Tests/ComparisonCaveatTests.cs | 6 +- tests/Bws.Core.Tests/DocumentValueGuards.cs | 22 ++++++-- tests/Bws.Core.Tests/ServiceTriggerTests.cs | 39 +++++++++++++ tests/Bws.Core.Tests/SnapshotGoldenTests.cs | 25 +++++++++ tests/mutation/entries.ps1 | 32 +++++++++++ 8 files changed, 207 insertions(+), 11 deletions(-) create mode 100644 tests/Bws.Core.Tests/ServiceTriggerTests.cs diff --git a/src/Bws.Core/ServiceTrigger.cs b/src/Bws.Core/ServiceTrigger.cs index 10b0d778..dbf58d60 100644 --- a/src/Bws.Core/ServiceTrigger.cs +++ b/src/Bws.Core/ServiceTrigger.cs @@ -93,7 +93,29 @@ public enum TriggerDataType /// machine this was written on, 38 are upper case, 41 lower case and some mix the two inside one value - /// so case is something the manager hands over, and a change of it may change whether the trigger fires. /// -public sealed record TriggerData(TriggerDataType Type, IReadOnlyList Values); +public sealed record TriggerData(TriggerDataType Type, IReadOnlyList Values) +{ + /// + /// The same type and the same values in the same order, spelled the same - not the same list object, + /// which is what a record compares a list by unless told otherwise. Review of PR #97. + /// + public bool Equals(TriggerData? other) => + other is not null && Type == other.Type && Values.SequenceEqual(other.Values, StringComparer.Ordinal); + + public override int GetHashCode() + { + var hash = new HashCode(); + + hash.Add(Type); + + foreach (var value in Values) + { + hash.Add(value, StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } +} /// /// One condition under which the manager starts or stops an entry by itself. @@ -117,4 +139,35 @@ public sealed record ServiceTrigger( { /// The data items in the order the manager holds them, empty when there are none. public IReadOnlyList Data { get; init; } = Data ?? []; + + /// + /// Equal when everything a trigger says is equal, its data item by item in the manager's order. + /// + /// Written out since the review of PR #97: until schema seven this record held two enumeration + /// values and was equal by them, and the list of data items would have quietly turned that into + /// "the same list object" - two readings of one unchanged trigger unequal. Nothing compares triggers + /// this way today, which is when a value type is cheapest to keep honest. + /// + public bool Equals(ServiceTrigger? other) => + other is not null + && Kind == other.Kind + && Action == other.Action + && Subtype == other.Subtype + && Data.SequenceEqual(other.Data); + + public override int GetHashCode() + { + var hash = new HashCode(); + + hash.Add(Kind); + hash.Add(Action); + hash.Add(Subtype); + + foreach (var item in Data) + { + hash.Add(item); + } + + return hash.ToHashCode(); + } } diff --git a/src/Bws.Core/Snapshots/EntryDocument.cs b/src/Bws.Core/Snapshots/EntryDocument.cs index aac55201..56309db0 100644 --- a/src/Bws.Core/Snapshots/EntryDocument.cs +++ b/src/Bws.Core/Snapshots/EntryDocument.cs @@ -19,9 +19,10 @@ public sealed record UnreadableDocument(int ErrorCode, string Message); /// Subtype and data since schema seven (2026-10-27, logic report C01, owner's decision): until /// then it held the kind and the action only. The subtype is the GUID in lower case, null when the /// manager points at none. The data is always a list in a seven, empty for the kinds that carry -/// none - and null only in a file written before seven, which never held it. That null is how a -/// comparison of a seven with an older file knows to compare kinds and actions alone -/// (). +/// none - and null only in a file written before seven, which never held it. A seven whose trigger +/// has no data list is refused when it is read (Snapshot.BrokenEntries, since the review of +/// PR #97). Whether a comparison looks at kinds and actions alone is decided by the two schema +/// numbers, never by this null (). /// public sealed record TriggerDocument( string Kind, string Action, string? Subtype, IReadOnlyList? Data); diff --git a/src/Bws.Core/Snapshots/Snapshot.cs b/src/Bws.Core/Snapshots/Snapshot.cs index a08c2f99..00fa0ef2 100644 --- a/src/Bws.Core/Snapshots/Snapshot.cs +++ b/src/Bws.Core/Snapshots/Snapshot.cs @@ -369,7 +369,7 @@ internal bool BrokenEntries(out string? failure) return true; } - if (Contradiction(entry) is { } said) + if ((Contradiction(entry) ?? TriggerGap(entry, Metadata.SchemaVersion)) is { } said) { failure = $"Entry {index + 1} ('{entry.ServiceName}') {said}"; @@ -427,4 +427,34 @@ internal bool BrokenEntries(out string? failure) ? $"holds a value for '{held}' and also says it was not read." : null; } + + /// + /// Which trigger of a schema seven entry is not whole, or nothing - since 2026-10-27, the review of + /// PR #97. + /// + /// A seven always writes a data list for every trigger, empty for the kinds that carry none, + /// and every item in it with a type and at least one value (). A trigger + /// without them is a file that does not hold what its schema promises - and two such files would + /// compare as equal on exactly what neither of them holds, which is the false "the same" a snapshot + /// is for avoiding. A rule of schema seven only: an older file never wrote the data, and its triggers + /// read with it as null. A trigger that is null as a whole is left alone, as it was before seven. + /// + private static string? TriggerGap(EntryDocument entry, int schema) + { + if (schema < ComparisonCaveats.WholeTriggersSince || entry.Triggers is null) + { + return null; + } + + var gap = entry.Triggers.ToList().FindIndex(trigger => trigger is not null && !Whole(trigger)); + + return gap < 0 + ? null + : $"has trigger {gap + 1} without its whole data list, which a version {schema} snapshot always holds."; + } + + /// A data list, and every item in it with a type and at least one value, none of them missing. + private static bool Whole(TriggerDocument trigger) => + trigger.Data is { } data + && data.All(item => item is { Type: not null, Values.Count: > 0 } && item.Values.All(value => value is not null)); } diff --git a/tests/Bws.Core.Tests/ComparisonCaveatTests.cs b/tests/Bws.Core.Tests/ComparisonCaveatTests.cs index 28e521bb..2a27800f 100644 --- a/tests/Bws.Core.Tests/ComparisonCaveatTests.cs +++ b/tests/Bws.Core.Tests/ComparisonCaveatTests.cs @@ -88,14 +88,16 @@ public void A_different_account_is_said_and_different_capitals_are_not_one(strin [Theory] [InlineData(3)] - [InlineData(7)] + [InlineData(8)] 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); + // Written as literals, like the number SnapshotContractTests pins: seven since 2026-10-27, when + // triggers became whole (logic report C01), and this file was the one that said so in CI. Assert.False(SnapshotJson.TryRead(text, out _, out var failure)); - Assert.Contains("versions 4 to 6", failure!, StringComparison.Ordinal); + Assert.Contains("versions 4 to 7", failure!, StringComparison.Ordinal); } [Fact] diff --git a/tests/Bws.Core.Tests/DocumentValueGuards.cs b/tests/Bws.Core.Tests/DocumentValueGuards.cs index abccbc1d..4e7f3f44 100644 --- a/tests/Bws.Core.Tests/DocumentValueGuards.cs +++ b/tests/Bws.Core.Tests/DocumentValueGuards.cs @@ -137,15 +137,29 @@ private static int Triggers(JsonElement document) { checkedValues += Member(trigger, "kind") + Member(trigger, "action"); - if (trigger.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array) - { - checkedValues += data.EnumerateArray().Sum(item => Member(item, "type")); - } + // A seven writes the list and every item's type always, so here an absence FAILS rather than + // counting as nothing checked - review of PR #97: the count floor in the test above is met by + // the other fields alone, and would have let every type go missing without a word. + Assert.True( + trigger.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array, + $"A trigger is written without its data list: {trigger}"); + + checkedValues += data.EnumerateArray().Sum(Typed); } return checkedValues; } + /// The type of one data item, which must be there - see the comment where this is called. + private static int Typed(JsonElement item) + { + Assert.True( + item.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String, + $"A trigger data item is written without its type: {item}"); + + return Member(item, "type"); + } + /// /// Arrays that name what could not be read rather than being values in their own right. /// diff --git a/tests/Bws.Core.Tests/ServiceTriggerTests.cs b/tests/Bws.Core.Tests/ServiceTriggerTests.cs new file mode 100644 index 00000000..b7e5a2e1 --- /dev/null +++ b/tests/Bws.Core.Tests/ServiceTriggerTests.cs @@ -0,0 +1,39 @@ +namespace Bws.Core.Tests; + +/// +/// A trigger is a value: two readings of one unchanged trigger are equal. +/// +/// Written for the review of PR #97. Until schema seven the record held two enumeration values and +/// was equal by them. Its list of data items would have made it equal only to itself - a record compares +/// a list by the object, not by what is in it. +/// +public sealed class ServiceTriggerTests +{ + [Fact] + public void Two_triggers_built_apart_from_the_same_reading_are_equal_and_hash_alike() + { + var first = Read("0497B57D-2E66-424F-A0C6-157CD5D41700"); + var second = Read("0497B57D-2E66-424F-A0C6-157CD5D41700"); + + Assert.NotSame(first.Data, second.Data); + Assert.Equal(first, second); + Assert.Equal(first.GetHashCode(), second.GetHashCode()); + } + + /// + /// Case is part of the value, for the reason the comparison of two snapshots gives: a network endpoint + /// trigger fires on an exact match of its data. + /// + [Fact] + public void A_trigger_whose_data_differs_only_in_case_is_a_different_trigger() + { + Assert.NotEqual(Read("0497B57D-2E66-424F-A0C6-157CD5D41700"), Read("0497b57d-2e66-424f-a0c6-157cd5d41700")); + } + + private static ServiceTrigger Read(string rpcInterface) => + new( + TriggerKind.NetworkEndpoint, + TriggerAction.Start, + Fakes.Specimens.RpcInterfaceEvent, + [new TriggerData(TriggerDataType.String, [rpcInterface]), new TriggerData(TriggerDataType.Level, ["4"])]); +} diff --git a/tests/Bws.Core.Tests/SnapshotGoldenTests.cs b/tests/Bws.Core.Tests/SnapshotGoldenTests.cs index 208b3cd1..239788ac 100644 --- a/tests/Bws.Core.Tests/SnapshotGoldenTests.cs +++ b/tests/Bws.Core.Tests/SnapshotGoldenTests.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json.Nodes; using Bws.Core.Snapshots; using Bws.Core.Tests.Fakes; @@ -215,6 +216,30 @@ public void A_change_of_case_in_trigger_data_is_a_difference_between_two_sevens_ Assert.Empty(narrowed.Changed); } + /// + /// A seven whose trigger has no data list is refused - review of PR #97. A seven always writes + /// the list, so a file without it does not hold what its schema promises, and two such files would + /// compare as equal on what neither holds. The same file labelled six reads, because a six never + /// wrote the data - the rule belongs to the schema, which is why the second half is here. + /// + [Fact] + public void A_version_seven_trigger_without_its_data_list_is_refused_and_a_six_is_not() + { + var tree = JsonNode.Parse(KeptText(Kept))!; + var appinfo = tree["entries"]!.AsArray().Single(entry => (string?)entry!["serviceName"] == "Appinfo")!; + + appinfo["triggers"]![0]!.AsObject().Remove("data"); + + var seven = tree.ToJsonString(); + + Assert.False(SnapshotJson.TryRead(seven, out _, out var failure)); + Assert.Contains("'Appinfo') has trigger 1 without its whole data list", failure!, StringComparison.Ordinal); + + tree["metadata"]!["schemaVersion"] = 6; + + Assert.True(SnapshotJson.TryRead(tree.ToJsonString(), out _, out failure), failure); + } + /// The text with one occurrence of a piece replaced, failing when the piece is not there. private static string Once(string text, string piece, string replacement) { diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index 0d593aef..95539d58 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -14834,6 +14834,38 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = 'false,' Test = 'SnapshotGoldenTests.A_version_six_file_is_read_and_its_triggers_are_compared_by_kind_and_action' } + @{ + # C01, review of PR #97: a seven whose trigger has no data list is refused when read. + Name = 'snapshot: a seven whose trigger is not whole is refused' + File = 'src\Bws.Core\Snapshots\Snapshot.cs' + From = '(Contradiction(entry) ?? TriggerGap(entry, Metadata.SchemaVersion))' + To = '(Contradiction(entry))' + Test = 'SnapshotGoldenTests.A_version_seven_trigger_without_its_data_list_is_refused_and_a_six_is_not' + } + @{ + # C01, review of PR #97: the rule is one of schema seven - a six never wrote the data. + Name = 'snapshot: the rule on whole triggers is a rule of seven' + File = 'src\Bws.Core\Snapshots\Snapshot.cs' + From = 'if (schema < ComparisonCaveats.WholeTriggersSince || entry.Triggers is null)' + To = 'if (schema < 0 || entry.Triggers is null)' + Test = 'SnapshotGoldenTests.A_version_seven_trigger_without_its_data_list_is_refused_and_a_six_is_not' + } + @{ + # C01, review of PR #97: a trigger is a value - equal by its data, not by the list object. + Name = 'triggers: a trigger is equal by its data items' + File = 'src\Bws.Core\ServiceTrigger.cs' + From = '&& Data.SequenceEqual(other.Data);' + To = '&& ReferenceEquals(Data, other.Data);' + Test = 'ServiceTriggerTests.Two_triggers_built_apart_from_the_same_reading_are_equal_and_hash_alike' + } + @{ + # C01, review of PR #97: case is part of a data item's value. + Name = 'triggers: a data item is equal by its values with their case' + File = 'src\Bws.Core\ServiceTrigger.cs' + From = 'Values.SequenceEqual(other.Values, StringComparer.Ordinal)' + To = 'Values.SequenceEqual(other.Values, StringComparer.OrdinalIgnoreCase)' + Test = 'ServiceTriggerTests.A_trigger_whose_data_differs_only_in_case_is_a_different_trigger' + } ) $mutations From b11085c852fca0bb176be1abbc0189ee602065d5 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 27 Oct 2026 23:08:07 +0100 Subject: [PATCH 3/3] Settle three new mcp advisories that only semgrep brings in Three advisories against mcp 1.29.0 were published on 2026-10-27 and turned the OSV gate red on every branch. mcp is in the scan only because semgrep requires it, and every semgrep release up to 1.182.1, the newest on PyPI that day, requires mcp==1.29.0 exactly, so the fixed 1.30.0 cannot be installed beside it. Two advisories are about the MCP client and one about an MCP server's token check. CI runs semgrep as a one-off semgrep scan, which is neither a client nor a server. Each entry carries that reason and expires on 2026-11-16 with the other entries about semgrep's dependencies, after which the gate is red again unless a semgrep release has moved past mcp 1.29.0. Co-Authored-By: Claude Opus 5.5 --- .github/osv-scanner.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/osv-scanner.toml b/.github/osv-scanner.toml index d2aa67ef..a048fe98 100644 --- a/.github/osv-scanner.toml +++ b/.github/osv-scanner.toml @@ -18,6 +18,21 @@ id = "GHSA-84m7-p3x7-pcfv" ignoreUntil = 2026-11-16 reason = "mcp 1.29.0 is here only because semgrep requires it, and CI runs semgrep as semgrep scan. The advisory is about an MCP server over Streamable HTTP, and nothing in this repository serves MCP. The fix is mcp 1.30.0, but every semgrep release from 1.180.0 to 1.182.1 requires mcp==1.29.0 exactly (read on the package index on 2026-10-26), so pip cannot install it. Remove this entry once a semgrep release allows mcp 1.30.0 or later." +[[IgnoredVulns]] +id = "GHSA-5h93-6whr-6q8j" +ignoreUntil = 2026-11-16 +reason = "mcp 1.29.0 is here only because semgrep requires it, for the reason given at GHSA-84m7-p3x7-pcfv above. The advisory, published 2026-10-27, is about the MCP client's HTTP transports following cross-origin redirects with custom headers and bodies. CI runs semgrep as semgrep scan (.github/workflows/security.yml), which acts as no MCP client and connects to no MCP server. The fix is mcp 1.30.0, and every semgrep release up to 1.182.1, the newest on 2026-10-27, requires mcp==1.29.0 exactly. Remove this entry once a semgrep release allows mcp 1.30.0 or later." + +[[IgnoredVulns]] +id = "GHSA-rwrf-2pqf-9j8j" +ignoreUntil = 2026-11-16 +reason = "mcp 1.29.0 is here only because semgrep requires it, for the reason given at GHSA-84m7-p3x7-pcfv above. The advisory, published 2026-10-27, is about the MCP client fetching $ref URLs chosen by a server while it validates tool results. CI runs semgrep as semgrep scan, which acts as no MCP client and validates no tool result. The fix is mcp 1.30.0, which pip cannot install next to any semgrep release up to 1.182.1. Remove this entry once a semgrep release allows mcp 1.30.0 or later." + +[[IgnoredVulns]] +id = "GHSA-w4fh-qvv9-3v23" +ignoreUntil = 2026-11-16 +reason = "mcp 1.29.0 is here only because semgrep requires it, for the reason given at GHSA-84m7-p3x7-pcfv above. The advisory, published 2026-10-27, is about an MCP server accepting bearer tokens issued for a different resource server. Nothing in this repository serves MCP, and CI runs semgrep as semgrep scan, which starts no server. The fix is mcp 1.30.0, which pip cannot install next to any semgrep release up to 1.182.1. Remove this entry once a semgrep release allows mcp 1.30.0 or later." + [[IgnoredVulns]] id = "GHSA-xcq7-p3pf-p88h" ignoreUntil = 2026-11-16