Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,31 @@ is not part of this repository.
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.
- `bws show` prints each trigger on a line of its own with what it waits for, and each of its data
items on a line under it - for example `Firewall port/Start Port opened` and under it
`500, UDP, %windir%\system32\svchost.exe, IKEEXT`. The ten subtypes Microsoft documents are named in
words (RPC interface, named pipe, joined or left a domain, port opened or closed, machine or user
policy, first IP address arrived, last one removed), any other one is shown as its GUID, and bytes
are shown in pairs the way `sc qtriggerinfo` shows them. A value holding a comma, a `|` or a quotation
mark, or starting or ending in a space, is shown in quotation marks, so one value never reads like
two. It used to print the kinds and actions only, all on one line.
- The details panel of the window has a *Triggers* section after the fields, one line per trigger with
its subtype and data drawn at the full width of the panel, and a copy of the entry carries it too.
The *Triggers* line among the advanced fields still names the kinds, once each.
- A comparison shows a change of triggers as one line per trigger that only one side has, with
"nothing" on the other side - in the terminal and as rows of the window's table. It used to show the
two whole lists of triggers as JSON, which for an entry losing one of thirteen triggers was a line of
about three thousand characters. `--json` is unchanged and still carries both whole lists. A snapshot
edited by hand into a trigger this program never writes - an empty one, or data without its type or
values - still shows the two whole lists for that entry.

### Fixed

- A list of triggers that Windows hands back damaged - too short for its own header, or counting more
triggers than it holds - is now reported as a field that could not be read. It used to come back as
no triggers at all, or as the triggers that fitted, as if the list were complete. Nothing like this
has been seen on a real machine.

- A restart no longer starts, at its end, a service this run did not stop - one somebody else was
already stopping when the step came, or one that was starting and then stopped by itself. The step
asked for nothing and only watched the service get there, and its line in a terminal now says so -
Expand Down
35 changes: 27 additions & 8 deletions src/Bws.Cli/DiffText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,9 @@ private static void Changed(StringBuilder text, string heading, IReadOnlyList<Ch
{
text.AppendLine(Texts.Of("cli.diff.entry", entry.ServiceName, entry.DisplayName));

foreach (var difference in entry.Differences)
foreach (var line in entry.Differences.SelectMany(Lines))
{
text.AppendLine(Texts.Of(
difference.Group == DifferenceGroup.RunningState
? "cli.diff.field.state"
: "cli.diff.field.configuration",
difference.Field,
Value(difference.Before),
Value(difference.After)));
text.AppendLine(line);
}

// Named, never counted as a change. A field one side could not read says nothing
Expand Down Expand Up @@ -213,6 +207,31 @@ private static void Summary(StringBuilder text, SnapshotDiff diff)
}
}

/// <summary>
/// One difference as the lines it reads in: "field: before -> after", and for the triggers one such
/// line per trigger only one side holds, the other side "nothing" (backlog 625, owner's decision "a
/// line per trigger", 2026-10-27). Until then the triggers were the two whole lists as JSON - about
/// three thousand characters on one line for an entry losing one of thirteen.
///
/// <b>The whole lists are still the answer when there are no trigger lines to give</b> - a field that
/// differs only as "none" against an empty list, which no real reading writes but a file can hold.
/// A difference reported with nothing under it would be the silence rule 8 forbids. And when a side
/// holds a trigger that is not one to say, which the core answers with no lines at all (since the
/// review of PR #98 - a null trigger read as "/", and a data item of nulls ended the run with code 1).
/// </summary>
private static IEnumerable<string> Lines(FieldDifference difference)
{
var key = difference.Group == DifferenceGroup.RunningState ? "cli.diff.field.state" : "cli.diff.field.configuration";

if (difference.Triggers is not { } change || change.Removed.Count + change.Added.Count == 0)
{
return [Texts.Of(key, difference.Field, Value(difference.Before), Value(difference.After))];
}

return change.Removed.Select(trigger => Texts.Of(key, difference.Field, TriggerWords.Line(trigger), Value(null)))
.Concat(change.Added.Select(trigger => Texts.Of(key, difference.Field, Value(null), TriggerWords.Line(trigger))));
}

/// <summary>
/// A missing value said in words rather than left blank.
///
Expand Down
48 changes: 36 additions & 12 deletions src/Bws.Cli/EntryReport.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Globalization;
using Bws.Core;
using Bws.Core.Snapshots;

namespace Bws.Cli;

Expand Down Expand Up @@ -34,7 +35,12 @@ internal static class EntryReport
/// </summary>
private readonly record struct Told(ReadOutcome Outcome, string Text, int Code);

private sealed record Field(string Key, Func<ScmEntry, Told> Reads);
/// <param name="Lines">
/// Whether the value is several lines of this tool's own making, joined by a line feed - the triggers,
/// since 2026-10-27. Every piece of such a value has been through <see cref="Texts.Of(string, object[])"/>
/// already, so the only line feeds left in it are the joins.
/// </param>
private sealed record Field(string Key, Func<ScmEntry, Told> Reads, bool Lines = false);

private const string Identity = "cli.show.group.basics";
private const string About = "cli.show.group.about";
Expand Down Expand Up @@ -85,7 +91,7 @@ private static readonly (string Heading, Field[] Fields)[] Catalogue =
new Field("cli.show.loadOrderGroup", entry => Say(entry.LoadOrderGroup, value => value)),
new Field("cli.show.errorControl", entry => Say(entry.ErrorControl, value => value.ToString())),
new Field("cli.show.dependsOn", entry => Say(entry.DependsOn, List)),
new Field("cli.show.triggers", entry => Say(entry.Triggers, Triggers)),
new Field("cli.show.triggers", entry => Say(entry.Triggers, Triggers), Lines: true),
new Field("cli.show.requiredPrivileges", entry => Say(entry.RequiredPrivileges, List)),
new Field("cli.show.securityDescriptor", entry => Say(entry.SecurityDescriptor, value => value))
])
Expand All @@ -112,7 +118,7 @@ internal static string Render(ScmEntry entry, bool full)
foreach (var (heading, fields) in Catalogue)
{
var shown = fields
.Select(field => (field.Key, Told: field.Reads(entry)))
.Select(field => (field.Key, Told: field.Reads(entry), field.Lines))
.Where(pair => full || pair.Told.Outcome != ReadOutcome.Absent)
.ToList();

Expand All @@ -134,15 +140,32 @@ internal static string Render(ScmEntry entry, bool full)
// what makes it scannable.
var width = shown.Max(pair => Texts.Of(pair.Key).Length);

lines.AddRange(shown.Select(pair => Texts.Of(
"cli.show.line",
Texts.Of(pair.Key).PadRight(width),
Spell(pair.Told))));
lines.AddRange(shown.SelectMany(pair => Aligned(Texts.Of(pair.Key).PadRight(width), Spell(pair.Told), pair.Lines)));
}

return string.Join(Environment.NewLine, lines);
}

/// <summary>
/// One field as the lines it prints: the label and the value, and for a value of several lines of this
/// tool's own making - the triggers, since 2026-10-27 (backlog 625) - every further line under the
/// first one's value rather than at the left edge.
///
/// <b>ONLY those values are split, and that is the half that matters.</b> A line break inside a value
/// that came from outside - a description, a path, a name out of a snapshot - is written out as
/// <c>&lt;U+000A&gt;</c> by <see cref="Texts.Of(string, object[])"/> since stability report C-4, because a
/// value free to break the line can print a line that looks like a field of this report. The first
/// version of this method split every value and <c>TerminalTextTests</c> went red on it.
/// </summary>
private static IEnumerable<string> Aligned(string label, string value, bool lines)
{
var parts = lines ? value.Split('\n') : [value];
var first = Texts.Of("cli.show.line", label, parts[0]);
var under = new string(' ', first.Length - Printable.Of(parts[0]).Length);

return parts.Skip(1).Select(part => under + part).Prepend(first);
}

/// <summary>
/// How one value reads once its state is taken into account.
///
Expand Down Expand Up @@ -212,12 +235,13 @@ private static Told Publisher(ScmEntry entry)
// - Unknown, Start and Stop - so its value name and the word for a person are the same string
// and there is nothing to disagree about. Turning it into a lookup would buy one more file and
// three more keys to say what it already says.
//
// A TRIGGER PER LINE WITH ITS SUBTYPE AND DATA SINCE 2026-10-27 (backlog 625), where it used to be
// the kinds and actions joined by commas - through the document form, so a live entry reads the
// way a comparison reads the same trigger.
private static string Triggers(IReadOnlyList<ServiceTrigger> triggers) => string.Join(
", ",
triggers.Select(trigger => Texts.Of(
"cli.show.trigger",
TriggerWords.Of(trigger.Kind),
trigger.Action.ToString())));
'\n',
triggers.SelectMany(trigger => TriggerWords.Lines(TriggerDocument.Of(trigger))));

/// <summary>
/// Megabytes with one decimal, the same as the listing, so the two agree about one entry.
Expand Down
17 changes: 17 additions & 0 deletions src/Bws.Cli/Resources/cli.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,23 @@
"cli.show.yes": "yes",
"cli.show.no": "no",
"cli.show.trigger": "{0}/{1}",
"cli.show.trigger.data": " {0}",
"cli.trigger.withSubtype": "{0} {1}",
"cli.trigger.withData": "{0} {1}",
"cli.trigger.level": "level {0}",
"cli.trigger.keywordAny": "any of {0}",
"cli.trigger.keywordAll": "all of {0}",
"cli.trigger.unknownType": "unknown type {0}",
"cli.trigger.subtype.rpcInterface": "RPC interface",
"cli.trigger.subtype.namedPipe": "Named pipe",
"cli.trigger.subtype.joinedDomain": "Joined a domain",
"cli.trigger.subtype.leftDomain": "Left a domain",
"cli.trigger.subtype.portOpened": "Port opened",
"cli.trigger.subtype.portClosed": "Port closed",
"cli.trigger.subtype.machinePolicy": "Machine policy",
"cli.trigger.subtype.userPolicy": "User policy",
"cli.trigger.subtype.firstAddressArrived": "First address arrived",
"cli.trigger.subtype.lastAddressRemoved": "Last address removed",
"cli.show.serviceName": "Service name",
"cli.show.displayName": "Display name",
"cli.show.description": "Description",
Expand Down
72 changes: 72 additions & 0 deletions src/Bws.Cli/TriggerWords.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Bws.Core;
using Bws.Core.Snapshots;

namespace Bws.Cli;

Expand Down Expand Up @@ -28,6 +29,77 @@ namespace Bws.Cli;
/// </summary>
internal static class TriggerWords
{
/// <summary>
/// One trigger as the lines <c>bws show</c> prints under its label: the kind, the action and the
/// subtype on the first, and each data item on a line of its own under it, indented (backlog 625,
/// owner's decision "B - data under the trigger", 2026-10-27).
///
/// <b>One item, one line</b>, because the items are what the documentation counts: a device trigger
/// fires on ANY of its hardware IDs, each an item, while a firewall trigger's one item holds four
/// values that must ALL match. Lines keep the first apart and the comma keeps the second together.
/// </summary>
internal static IEnumerable<string> Lines(TriggerDocument trigger) =>
Items(trigger).Select(item => Texts.Of("cli.show.trigger.data", item)).Prepend(Head(trigger));

/// <summary>
/// One trigger on one line, for a comparison, which says "before -> after" per line - the items on one
/// line as <see cref="TriggerParts.OneLine"/> joins them, the same as the window.
/// </summary>
internal static string Line(TriggerDocument trigger)
{
var items = Items(trigger).ToList();

return items.Count == 0 ? Head(trigger) : Texts.Of("cli.trigger.withData", Head(trigger), TriggerParts.OneLine(items));
}

/// <summary>
/// The kind and the action, and the subtype after them - in words for the ten the documentation names,
/// as the GUID otherwise. None at all for a trigger out of a file older than schema seven.
/// </summary>
private static string Head(TriggerDocument trigger)
{
var head = Texts.Of("cli.show.trigger", Kind(trigger.Kind), trigger.Action);

return trigger.Subtype is { } subtype
? Texts.Of("cli.trigger.withSubtype", head, TriggerParts.Subtype(trigger) is { } name ? Subtype(name) : subtype)
: head;
}

/// <summary>
/// A kind as a document names it, in words - and a name this build does not know left as it is, since
/// a file written by a later build may hold one, and "unknown" would claim less than the file says.
/// </summary>
private static string Kind(string? kind) =>
kind is not null && Enum.GetNames<TriggerKind>().Contains(kind, StringComparer.Ordinal)
? Of(Enum.Parse<TriggerKind>(kind))
: kind ?? Texts.Of("cli.cell.unknown");

/// <summary>Each data item as one piece of text, the type's word around its values.</summary>
private static IEnumerable<string> Items(TriggerDocument trigger) =>
(trigger.Data ?? []).Select(item => TriggerParts.TypeOf(item) switch
{
TriggerDataType.Level => Texts.Of("cli.trigger.level", TriggerParts.Values(item)),
TriggerDataType.KeywordAny => Texts.Of("cli.trigger.keywordAny", TriggerParts.Values(item)),
TriggerDataType.KeywordAll => Texts.Of("cli.trigger.keywordAll", TriggerParts.Values(item)),
TriggerDataType.Unknown => Texts.Of("cli.trigger.unknownType", TriggerParts.Values(item)),
_ => TriggerParts.Values(item)
});

private static string Subtype(TriggerSubtypeName name) => name switch
{
TriggerSubtypeName.RpcInterface => Texts.Of("cli.trigger.subtype.rpcInterface"),
TriggerSubtypeName.NamedPipe => Texts.Of("cli.trigger.subtype.namedPipe"),
TriggerSubtypeName.JoinedDomain => Texts.Of("cli.trigger.subtype.joinedDomain"),
TriggerSubtypeName.LeftDomain => Texts.Of("cli.trigger.subtype.leftDomain"),
TriggerSubtypeName.PortOpened => Texts.Of("cli.trigger.subtype.portOpened"),
TriggerSubtypeName.PortClosed => Texts.Of("cli.trigger.subtype.portClosed"),
TriggerSubtypeName.MachinePolicy => Texts.Of("cli.trigger.subtype.machinePolicy"),
TriggerSubtypeName.UserPolicy => Texts.Of("cli.trigger.subtype.userPolicy"),
TriggerSubtypeName.FirstAddressArrived => Texts.Of("cli.trigger.subtype.firstAddressArrived"),
TriggerSubtypeName.LastAddressRemoved => Texts.Of("cli.trigger.subtype.lastAddressRemoved"),
_ => name.ToString()
};

internal static string Of(TriggerKind kind) => kind switch
{
TriggerKind.DeviceArrival => Texts.Of("cli.cell.trigger.deviceArrival"),
Expand Down
Loading
Loading