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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,38 @@ is not part of this repository.
The refusal names the services Windows did not show, or says that the list of them could not be
read. Where an account is shown every service - as an administrator was on the machine this was
measured on - nothing changes.
- `bws snapshot create` without `--force` no longer replaces a snapshot that appeared under the same
name while the machine was being read. Two runs started together by a script, naming the file from
the same second, used to leave only the second snapshot. The second one now says a snapshot is
already there and writes nothing.
- A snapshot too large for this version to read back - over 64 MB, which a very long note could make -
is no longer written. It used to be written and announced, and then refused by the same version as
not a snapshot it wrote. The terminal and the window now say why nothing was written. A path that is
not a file on a disk, such as `NUL`, is refused as one rather than read.
- A snapshot file that begins with a byte order mark is now refused when a byte in it is not valid
text, as a file without the mark always was. With the mark, the broken byte used to be read as a
replacement character, and a comparison reported the damage as a change on the machine.
- A snapshot that says two things about one field that cannot both be true - holds a value for it and
lists it as not read, or lists it both as refused and as never read - or that holds an entry with an
empty name, is refused when it is read, naming the entry and the field. Such a file used to be read,
and the comparison quietly left the field out.
- A comparison now reports a change in the case of a service's launch arguments - `--tenant=ACME`
becoming `--tenant=acme` - as a change of configuration. The whole launch command used to be
compared without case, because Windows paths ignore case, and the arguments are read by the
service's own program, which may not. The path is still compared without case.
- Comparing a snapshot with this machine (`bws snapshot diff --live`, and *Compare with this machine*
in the window) no longer calls a service removed when Windows still has it but no longer shows it to
this account - after its permissions changed, for example. Every service the reading lacks is asked
about by name. One Windows still holds is listed under its own heading as still there and not
compared, and is not a change of configuration - in `--json` it is in a new `unlisted` list at the
end of the document. One Windows no longer holds is removed, also when the snapshot was taken with
administrator rights and the comparison runs without them, where the answer used to be "cannot tell".
- The caveat about fields neither side of a comparison holds now says it applies to at least one entry.
It used to say no entry was compared on them, also when only one entry lacked them.
- In the window, a comparison that finishes after you have gone to another tab, opened the form for a
new snapshot or put the comparison away no longer takes the middle of the window back - the line at
the foot says it finished and was not shown. Saving a snapshot no longer closes a form for a new
snapshot opened while the first one was being read.
- `bws kill`, and Force stop and Force restart in the window, now ask the other services living in the
same process to stop in an order Windows accepts: a service another of them needs is asked after
it. They used to be asked in the order of the list, so a service another one still needed was
Expand Down
14 changes: 12 additions & 2 deletions src/Bws.Cli/DiffJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ [.. diff.Changed.Select(Changed)],
[.. diff.NotFullyCompared.Select(Changed)],
diff.NeitherRead,
new LeftOutDocument(diff.LeftOut.Earlier, diff.LeftOut.Later),
[.. diff.RefusedByBoth.Select(field => new RefusedDocument(field.Field, field.Entries))]),
[.. diff.RefusedByBoth.Select(field => new RefusedDocument(field.Field, field.Entries))],
[.. diff.Unlisted.Select(Presence)]),
Options);

private static PresenceDocument Presence(EntryPresence entry) =>
Expand Down Expand Up @@ -93,6 +94,14 @@ private static ChangedDocument Changed(ChangedEntry entry) =>
/// <b>What moved, said for whoever reads <paramref name="NotFullyCompared"/>:</b> an entry whose
/// only uncompared field was refused to both sides is no longer there - it is named here instead.
/// </param>
/// <param name="Unlisted">
/// Entries the earlier snapshot holds that this machine's listing did not, and that the service
/// manager, asked by name, says it still holds - only a comparison with this machine fills it (logic
/// report S03). Added 2026-10-27 and LAST, so the document only grew at its end, and its own key on
/// the owner's decision (backlog 623): beside <paramref name="Uncertain"/> a script could not tell
/// "the other side may not have been shown it" from "the manager says it is there", and a person is
/// shown the two under two headings. Never drift, so never part of <paramref name="Differs"/>.
/// </param>
private sealed record DiffDocument(
bool Differs,
CaveatsDocument Caveats,
Expand All @@ -103,7 +112,8 @@ private sealed record DiffDocument(
IReadOnlyList<ChangedDocument> NotFullyCompared,
IReadOnlyList<string> NeitherRead,
LeftOutDocument InstancesLeftOut,
IReadOnlyList<RefusedDocument> RefusedByBoth);
IReadOnlyList<RefusedDocument> RefusedByBoth,
IReadOnlyList<PresenceDocument> Unlisted);

private sealed record RefusedDocument(string Field, IReadOnlyList<string> Entries);

Expand Down
17 changes: 12 additions & 5 deletions src/Bws.Cli/DiffText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ internal static string Render(SnapshotDiff diff)
// of these files could not see them" are different sentences and only one is a fact.
Presence(text, "cli.diff.uncertain", diff.Uncertain);

// And its own again, since 2026-10-27 (logic report S03): these the manager says it still holds,
// which is a fact rather than a "cannot tell" - only this account was not shown them.
Presence(text, "cli.diff.unlisted", diff.Unlisted);

Changed(text, "cli.diff.changed", diff.Changed);

// Also its own heading, for the same reason one level down. These entries have
Expand Down Expand Up @@ -180,7 +184,10 @@ private static void Summary(StringBuilder text, SnapshotDiff diff)
// Said in the summary too, not only above. Somebody who reads the last line and
// stops must not come away with "nothing changed" when part of it was never looked
// at - that is the difference between an answer and a comfortable one.
if (diff.NotFullyCompared.Count > 0 || diff.Uncertain.Count > 0)
// Entries the manager holds and did not list are seen by one side too - the snapshot's.
var seenByOne = diff.Uncertain.Count + diff.Unlisted.Count;

if (diff.NotFullyCompared.Count > 0 || seenByOne > 0)
{
// A HEADING AND A LINE PER COUNT SINCE 2026-09-01, WHERE IT USED TO BE ONE SENTENCE
// HOLDING BOTH - backlog 207. One sentence could not carry a singular, because each
Expand All @@ -196,11 +203,11 @@ private static void Summary(StringBuilder text, SnapshotDiff diff)
: Texts.Of("cli.diff.incomplete.neverRead.many", diff.NotFullyCompared.Count));
}

if (diff.Uncertain.Count > 0)
if (seenByOne > 0)
{
text.AppendLine(diff.Uncertain.Count == 1
? Texts.Of("cli.diff.incomplete.seenByOne.one", diff.Uncertain.Count)
: Texts.Of("cli.diff.incomplete.seenByOne.many", diff.Uncertain.Count));
text.AppendLine(seenByOne == 1
? Texts.Of("cli.diff.incomplete.seenByOne.one", seenByOne)
: Texts.Of("cli.diff.incomplete.seenByOne.many", seenByOne));
}
}
}
Expand Down
29 changes: 6 additions & 23 deletions src/Bws.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
return laterFailed;
}

if (!SnapshotFiles.Compare(before!, after!, out var difference, out var refused))
if (!SnapshotFiles.Compare(before!, after!, options.Live ? catalog : null, out var difference, out var refused))
{
stopwatch.Stop();

Expand Down Expand Up @@ -253,35 +253,18 @@
//
// NOTHING IS MOVED ASIDE HERE ANY MORE, since 2026-10-06 - security report S-8. A file this
// build cannot read as a snapshot used to go into quarantine so it could be replaced, and now
// MayWrite refuses it instead and leaves it where it is.
if (!SnapshotFiles.MayWrite(target, options.Force, out var refusal))
// it is refused instead and left where it is.
//
// ASKED BY THE WRITE ITSELF SINCE 2026-10-27 (logic report R11 and S02) - the question used to
// stand here and the write after it always replaced, so a file arriving between the two went.
if (!SnapshotFiles.TryWrite(target, snapshot, options.Force, out var refusal))
{
stopwatch.Stop();
Console.Error.WriteLine(refusal);

return ExitCode.Usage;
}

try
{
AtomicFile.Write(target, SnapshotJson.Render(snapshot));
}
catch (Exception theirs) when (theirs is DirectoryNotFoundException or UnauthorizedAccessException)
{
// What somebody typed, not something that went wrong inside. ADR-18 forbids
// inventing directories, so the honest answer is the one the usage code carries.
//
// BEING REFUSED THE FOLDER JOINED IT 2026-09-03 - backlog 308(d). It is the same
// sentence about the same thing: a path somebody chose that this account cannot write
// to. It used to fall through to the entry point, come back as code 1, and tell a
// script that the machine had failed - which is the distinction docs/12 step 5 asks
// about and the one audit.ps1 cannot check, because it asks whether a code is in the
// table rather than whether it is the right one.
stopwatch.Stop();
Console.Error.WriteLine(theirs.Message);
return ExitCode.Usage;
}

stopwatch.Stop();

data = options.Json
Expand Down
4 changes: 3 additions & 1 deletion src/Bws.Cli/Resources/cli.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"cli.snapshot.fileExists": "There is already a snapshot at {0}. A snapshot is kept and compared later, so this will not replace one without being told to. Pick another name, or add --force.",
"cli.snapshot.notReplaced": "Only a snapshot is ever replaced, so the file was left where it is and nothing was written, with or without --force. Pick another name, or move the file yourself.",
"cli.snapshot.isFolder": "{0} is a folder, and a snapshot is written as one file. Name a file inside it instead.",
"cli.snapshot.wouldBeTooBig": "Nothing was written to {0}: the snapshot would be larger than {1} MB, and this tool does not read back a file that large. A snapshot of a whole machine is about one megabyte - if a long --note was given, shorten it.",
"cli.optionNeedsValue": "{0} needs a value after it.",
"cli.optionNotForCommand": "{0} does not apply to {1}. It works with: {2}.",
"cli.missingServiceName": "{0} needs the name of a service. Example: bws {0} Spooler --dry-run",
Expand Down Expand Up @@ -315,6 +316,7 @@
"cli.diff.added": "Added ({0}):",
"cli.diff.removed": "Removed ({0}):",
"cli.diff.uncertain": "Cannot tell ({0}) - present in the elevated snapshot, and the other was taken without elevation, so the manager may simply not have handed these over:",
"cli.diff.unlisted": "Still there ({0}) - Windows still holds these, but leaves them out of the list it shows this account, so they were not compared:",
"cli.diff.changed": "Changed ({0}):",
"cli.diff.notFullyCompared": "Not fully compared ({0}) - nothing differs, and a field one of the two could not put a value to, so nobody can say either way:",
"cli.diff.entry": " {0} {1}",
Expand All @@ -337,7 +339,7 @@
"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.notKnown": "At least one of the snapshots does not record these - it was written before they were, or the machine would not say - so they could not be checked: {0}",
"cli.diff.caveat.instancesLeftOut": "Per-user session copies come and go with the people signed in, so they are not compared - earlier snapshot: {0}, later snapshot: {1}.",
"cli.diff.caveat.neitherRead": "Neither snapshot holds these fields, so no entry was compared on them: {0}",
"cli.diff.caveat.neitherRead": "On at least one entry neither snapshot holds these fields, so those entries were not compared on them: {0}",
"cli.diff.caveat.refusedByBoth.many": "Windows would not give {0} to either snapshot for {1} entries, so they were not compared on it: {2}",
"cli.diff.caveat.refusedByBoth.one": "Windows would not give {0} to either snapshot for {1} entry, so it was not compared on it: {2}",
"cli.info.timingCompared": "Read and compared two snapshots in {0} ms.",
Expand Down
61 changes: 53 additions & 8 deletions src/Bws.Cli/SnapshotFiles.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Bws.Core;
using Bws.Core.Snapshots;

namespace Bws.Cli;
Expand Down Expand Up @@ -30,17 +31,55 @@ internal static string Target(string given, SnapshotMetadata metadata) =>
/// </summary>
internal static bool MayWrite(string target, bool force, out string? refusal)
{
refusal = SnapshotOnDisk.WhyNotWritten(target, force) switch
refusal = SnapshotOnDisk.WhyNotWritten(target, force) is { } refused ? NotWritten(refused) : null;

return refusal is null;
}

/// <summary>
/// Writes the snapshot, or gives the sentence saying why it was not - the rule is
/// <see cref="SnapshotOnDisk.Write"/>, which asks again what stands there and refuses in the move itself
/// when nobody said a snapshot may be replaced.
/// </summary>
internal static bool TryWrite(string target, Snapshot snapshot, bool force, out string? refusal)
{
try
{
refusal = SnapshotOnDisk.Write(target, SnapshotJson.Render(snapshot), force) is { } refused
? NotWritten(refused)
: null;
}
catch (Exception theirs) when (theirs is DirectoryNotFoundException or UnauthorizedAccessException)
{
null => null,
{ Fault: DiskFault.IsFolder } => Texts.Of("cli.snapshot.isFolder", target),
{ Fault: DiskFault.AlreadyThere } => Texts.Of("cli.snapshot.fileExists", target),
var other => Said(other) + " " + Texts.Of("cli.snapshot.notReplaced")
};
// What somebody typed, not something that went wrong inside. ADR-18 forbids
// inventing directories, so the honest answer is the one the usage code carries.
//
// BEING REFUSED THE FOLDER JOINED IT 2026-09-03 - backlog 308(d). It is the same
// sentence about the same thing: a path somebody chose that this account cannot write
// to. It used to fall through to the entry point, come back as code 1, and tell a
// script that the machine had failed - which is the distinction docs/12 step 5 asks
// about and the one audit.ps1 cannot check, because it asks whether a code is in the
// table rather than whether it is the right one. Moved here from Program.cs with the
// write, 2026-10-27.
refusal = theirs.Message;
}

return refusal is null;
}

/// <summary>
/// Why a snapshot was not written, and - for what is not a snapshot - that it was left alone. Internal
/// rather than private because one refusal, a snapshot too large to read back, takes sixty four
/// megabytes to reach from outside, and a sentence nothing can reach is a sentence nothing checks.
/// </summary>
internal static string NotWritten(DiskRefusal refusal) => refusal switch
{
{ Fault: DiskFault.IsFolder } => Texts.Of("cli.snapshot.isFolder", refusal.Path),
{ Fault: DiskFault.AlreadyThere } => Texts.Of("cli.snapshot.fileExists", refusal.Path),
{ Fault: DiskFault.WouldBeTooBig } => Texts.Of("cli.snapshot.wouldBeTooBig", refusal.Path, SnapshotOnDisk.Biggest / (1024 * 1024)),
_ => Said(refusal) + " " + Texts.Of("cli.snapshot.notReplaced")
};

/// <summary>
/// Reads one snapshot from disk, or says what is wrong with what was pointed at.
/// </summary>
Expand Down Expand Up @@ -87,12 +126,18 @@ internal static bool Load(string path, out Snapshot? snapshot, out int code)
/// Annotated rather than left for the caller to assert with an exclamation mark. The compiler then
/// knows what the return value already meant.
/// </param>
/// <param name="machine">
/// The manager the later side was read from, for a comparison with this machine - asked by name about
/// every entry the earlier side holds and the reading lacks (logic report S03). Null for two files.
/// </param>
internal static bool Compare(
Snapshot before, Snapshot after, [NotNullWhen(true)] out SnapshotDiff? difference, out int code)
Snapshot before, Snapshot after, IScmCatalog? machine, [NotNullWhen(true)] out SnapshotDiff? difference, out int code)
{
code = ExitCode.Ok;

if (SnapshotDiff.TryBetween(before, after, out difference, out var failure))
if (machine is null
? SnapshotDiff.TryBetween(before, after, out difference, out var failure)
: SnapshotDiff.TryAgainstMachine(before, after, machine, out difference, out failure))
{
return true;
}
Expand Down
7 changes: 6 additions & 1 deletion src/Bws.Core/BinaryPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,11 @@ private static string FromTheObjectNamespace(string rest)
return rest.Length > 1 && rest[1] == ':' ? rest : Win32Device + rest;
}

private static bool HasExecutableExtension(string path) =>
/// <summary>
/// Whether a name ends the way a file Windows runs or loads ends. Internal since 2026-10-27, for the
/// comparison of two launch commands, which has to find where the file ends without a disk to ask
/// (SnapshotDiff.Values.cs) - one list, so the two never disagree about what counts.
/// </summary>
internal static bool HasExecutableExtension(string path) =>
ExecutableExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);
}
18 changes: 18 additions & 0 deletions src/Bws.Core/IScmCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,22 @@ public interface IScmCatalog
/// 05-PRZYPADKI-BRZEGOWE, because it decides whether a cascade has to recurse.
/// </summary>
Reading<IReadOnlyList<string>> ReadDependents(string serviceName);

/// <summary>
/// Which of these names the manager holds as entries right now, whether or not it lists them to this
/// account - since 2026-10-27, logic report S03.
///
/// <b>Worth doubling because the listing is not the manager's word on what exists.</b> The call behind
/// <see cref="ReadAll"/> leaves out, without an error, every entry this account may not query
/// (Microsoft's page on <c>EnumServicesStatusEx</c>), so a snapshot compared with this machine called
/// such an entry removed. Asked by name, the manager says which of the missing ones it still has.
///
/// A name it holds and refuses to open counts as held - the refusal is about this account, and only a
/// name it does not hold is answered with "does not exist".
/// </summary>
/// <exception cref="System.ComponentModel.Win32Exception">
/// The manager could not be opened - the same failure as <see cref="ReadAll"/>, and for the same
/// reason a whole answer rather than a part of one.
/// </exception>
IReadOnlyList<string> ReadHeld(IReadOnlyList<string> names);
}
Loading
Loading