diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd4c07d..8f9ddd10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/Bws.Cli/DiffJson.cs b/src/Bws.Cli/DiffJson.cs index b93a5632..94459862 100644 --- a/src/Bws.Cli/DiffJson.cs +++ b/src/Bws.Cli/DiffJson.cs @@ -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) => @@ -93,6 +94,14 @@ private static ChangedDocument Changed(ChangedEntry entry) => /// What moved, said for whoever reads : an entry whose /// only uncompared field was refused to both sides is no longer there - it is named here instead. /// + /// + /// 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 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 . + /// private sealed record DiffDocument( bool Differs, CaveatsDocument Caveats, @@ -103,7 +112,8 @@ private sealed record DiffDocument( IReadOnlyList NotFullyCompared, IReadOnlyList NeitherRead, LeftOutDocument InstancesLeftOut, - IReadOnlyList RefusedByBoth); + IReadOnlyList RefusedByBoth, + IReadOnlyList Unlisted); private sealed record RefusedDocument(string Field, IReadOnlyList Entries); diff --git a/src/Bws.Cli/DiffText.cs b/src/Bws.Cli/DiffText.cs index bf0e5a6b..a48531bb 100644 --- a/src/Bws.Cli/DiffText.cs +++ b/src/Bws.Cli/DiffText.cs @@ -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 @@ -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 @@ -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)); } } } diff --git a/src/Bws.Cli/Program.cs b/src/Bws.Cli/Program.cs index cc026b4a..86a9e728 100644 --- a/src/Bws.Cli/Program.cs +++ b/src/Bws.Cli/Program.cs @@ -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(); @@ -253,8 +253,11 @@ // // 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); @@ -262,26 +265,6 @@ 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 diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 781cb11e..8670cb15 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -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", @@ -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}", @@ -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.", diff --git a/src/Bws.Cli/SnapshotFiles.cs b/src/Bws.Cli/SnapshotFiles.cs index 35e0bd9b..eec44ac7 100644 --- a/src/Bws.Cli/SnapshotFiles.cs +++ b/src/Bws.Cli/SnapshotFiles.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Bws.Core; using Bws.Core.Snapshots; namespace Bws.Cli; @@ -30,17 +31,55 @@ internal static string Target(string given, SnapshotMetadata metadata) => /// 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; + } + + /// + /// Writes the snapshot, or gives the sentence saying why it was not - the rule is + /// , which asks again what stands there and refuses in the move itself + /// when nobody said a snapshot may be replaced. + /// + 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; } + /// + /// 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. + /// + 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") + }; + /// /// Reads one snapshot from disk, or says what is wrong with what was pointed at. /// @@ -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. /// + /// + /// 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. + /// 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; } diff --git a/src/Bws.Core/BinaryPathResolver.cs b/src/Bws.Core/BinaryPathResolver.cs index 4bdb9066..4ac815a8 100644 --- a/src/Bws.Core/BinaryPathResolver.cs +++ b/src/Bws.Core/BinaryPathResolver.cs @@ -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) => + /// + /// 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. + /// + internal static bool HasExecutableExtension(string path) => ExecutableExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); } diff --git a/src/Bws.Core/IScmCatalog.cs b/src/Bws.Core/IScmCatalog.cs index 47e892c3..88819f01 100644 --- a/src/Bws.Core/IScmCatalog.cs +++ b/src/Bws.Core/IScmCatalog.cs @@ -73,4 +73,22 @@ public interface IScmCatalog /// 05-PRZYPADKI-BRZEGOWE, because it decides whether a cascade has to recurse. /// Reading> ReadDependents(string serviceName); + + /// + /// 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. + /// + /// Worth doubling because the listing is not the manager's word on what exists. The call behind + /// leaves out, without an error, every entry this account may not query + /// (Microsoft's page on EnumServicesStatusEx), 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". + /// + /// + /// The manager could not be opened - the same failure as , and for the same + /// reason a whole answer rather than a part of one. + /// + IReadOnlyList ReadHeld(IReadOnlyList names); } diff --git a/src/Bws.Core/Planning/DependentsAskedOnce.cs b/src/Bws.Core/Planning/DependentsAskedOnce.cs index e70bfb35..6b8a07f1 100644 --- a/src/Bws.Core/Planning/DependentsAskedOnce.cs +++ b/src/Bws.Core/Planning/DependentsAskedOnce.cs @@ -33,6 +33,8 @@ internal sealed class DependentsAskedOnce(IScmCatalog catalog) : IScmCatalog public IReadOnlyList ReadStatuses() => catalog.ReadStatuses(); + public IReadOnlyList ReadHeld(IReadOnlyList names) => catalog.ReadHeld(names); + public Reading> ReadDependents(string serviceName) { if (!_answers.TryGetValue(serviceName, out var answer)) diff --git a/src/Bws.Core/Snapshots/AtomicFile.cs b/src/Bws.Core/Snapshots/AtomicFile.cs index 5fac329d..cc0646d4 100644 --- a/src/Bws.Core/Snapshots/AtomicFile.cs +++ b/src/Bws.Core/Snapshots/AtomicFile.cs @@ -48,10 +48,38 @@ public static class AtomicFile private static readonly UTF8Encoding Encoding = new(encoderShouldEmitUTF8Identifier: false); /// - /// Puts the text at the path, atomically. + /// ERROR_ALREADY_EXISTS and ERROR_FILE_EXISTS as the carries them - + /// what a move that may not replace says when something stands at the target. Measured 2026-10-27 on .NET + /// 10.0.13: a file there and a folder there both answer the first. + /// + private const int AlreadyExists = unchecked((int)0x800700B7); + + private const int FileExists = unchecked((int)0x80070050); + + /// + /// Puts the text at the path, atomically, replacing whatever file stands there. + /// + /// The directory does not exist. + public static void Write(string path, string content) => Land(path, content, replace: true); + + /// + /// Puts the text at the path, atomically, only if nothing stands there at the moment it lands - false, + /// with nothing written and nothing left beside it, when something does. + /// + /// Since 2026-10-27, logic report R11. Every caller that may not replace asks first whether + /// something is there, and the answer was true only until the move: two runs writing one name, or a + /// file appearing while the machine was read, slipped between the question and a move that always + /// replaced. The refusal now lives in the move itself, which Windows makes in one step. + /// + /// Where "nothing left beside it" stops, said because the sentence above is a promise: the + /// temporary file is deleted the way every failure here deletes it, and a delete that something else + /// refuses at that instant is not reported ( says why). Something would have to + /// hold, at that instant, a file this method created microseconds earlier under a random name. /// /// The directory does not exist. - public static void Write(string path, string content) + public static bool WriteNew(string path, string content) => Land(path, content, replace: false); + + private static bool Land(string path, string content, bool replace) { var full = Path.GetFullPath(path); var directory = Path.GetDirectoryName(full); @@ -83,7 +111,23 @@ public static void Write(string path, string content) try { File.WriteAllText(beside, content, Encoding); - File.Move(beside, full, overwrite: true); + File.Move(beside, full, overwrite: replace); + + return true; + } + catch (IOException refused) when (!replace) + { + // The temporary file goes either way, for the reason the clause below gives. + Delete(beside); + + // Something got there first, and it stays exactly as it was - the caller is told, and says + // so. Any other refusal of the move is the caller's to hear about as it was. + if (refused.HResult is AlreadyExists or FileExists) + { + return false; + } + + throw; } catch { diff --git a/src/Bws.Core/Snapshots/EntryDocument.cs b/src/Bws.Core/Snapshots/EntryDocument.cs index 0b12dda9..665db4d0 100644 --- a/src/Bws.Core/Snapshots/EntryDocument.cs +++ b/src/Bws.Core/Snapshots/EntryDocument.cs @@ -397,5 +397,12 @@ private static void Note( } } - private static string Camel(string name) => char.ToLowerInvariant(name[0]) + name[1..]; + /// + /// A field's name the way the document spells its key - and the way the names inside "unreadable" and + /// "notRead" are read back, since a file written by hand may carry them capitalised, as the property + /// names they come from are. One spelling rule for the writer, the comparison and the check of a + /// document read back (Snapshot.BrokenEntries), since 2026-10-27 - the comparison had its own copy. + /// + internal static string Camel(string name) => + name.Length == 0 ? name : char.ToLowerInvariant(name[0]) + name[1..]; } diff --git a/src/Bws.Core/Snapshots/Snapshot.cs b/src/Bws.Core/Snapshots/Snapshot.cs index 8cc1a11b..e77ae0fa 100644 --- a/src/Bws.Core/Snapshots/Snapshot.cs +++ b/src/Bws.Core/Snapshots/Snapshot.cs @@ -337,6 +337,16 @@ internal bool BrokenEntries(out string? failure) return true; } + // EMPTY IS NOT A NAME EITHER, since 2026-10-27 - logic report S06. The manager holds no + // entry without a name, so the document describes no machine, and a comparison would match + // it against nothing and report it as an entry that came or went. + if (entry.ServiceName.Length == 0) + { + failure = $"Entry {index + 1} has an empty service name."; + + return true; + } + // THE ELEMENTS OF ONE LIST, since 2026-09-30 - stability report D-3, measured: a copy of a // real snapshot with `"notRead": [null]` in one entry ended `bws snapshot diff` with // "Object reference not set to an instance of an object." and code 1. The comparison @@ -350,6 +360,13 @@ internal bool BrokenEntries(out string? failure) return true; } + if (Contradiction(entry) is { } said) + { + failure = $"Entry {index + 1} ('{entry.ServiceName}') {said}"; + + return true; + } + if (!seen.Add(entry.ServiceName)) { failure = $"More than one entry is called '{entry.ServiceName}'."; @@ -362,4 +379,43 @@ internal bool BrokenEntries(out string? failure) return false; } + + /// + /// What one entry says about a field that cannot all be true, or nothing - since 2026-10-27, logic + /// report S06, owner's decision "refuse contradictions". + /// + /// Two shapes, and this build writes neither (EntryDocument.From puts a field in one place by + /// the outcome of its reading): a field named both as refused and as never asked about, and a field + /// holding a value while either list says it was not read. Until that day both were read, and the + /// comparison believed the list - a value beside "not read" was left out of the comparison, so a + /// startup type that changed was reported as one nobody could compare. + /// + /// Asked of the entry as the comparison sees it (), so a + /// field the comparison drops - memory - is not held against a list naming it. Unknown names and + /// unknown values are left alone, as the decision said: a later build may write a field this one + /// has never heard of. + /// + private static string? Contradiction(EntryDocument entry) + { + // Lists rather than sets, so the field named is the first one the file names - the same file gives + // the same sentence every time. Both are a handful of names. + var notRead = (entry.NotRead ?? []).Select(EntryDocument.Camel).ToList(); + var refused = (entry.Unreadable?.Keys ?? Enumerable.Empty()).Select(EntryDocument.Camel).ToList(); + + if (notRead.Count == 0 && refused.Count == 0) + { + return null; + } + + if (notRead.FirstOrDefault(refused.Contains) is { } both) + { + return $"names '{both}' both as refused and as never asked about."; + } + + var document = SnapshotJson.Document(entry); + + return notRead.Concat(refused).FirstOrDefault(field => document[field] is not null) is { } held + ? $"holds a value for '{held}' and also says it was not read." + : null; + } } diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs b/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs index 23a31c1d..d509768e 100644 --- a/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs +++ b/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs @@ -10,21 +10,48 @@ namespace Bws.Core.Snapshots; /// public sealed partial record SnapshotDiff { + /// + /// The entries the earlier side holds and the later one does not, each put where it belongs - and the + /// ones the manager still holds handed back, in the order they came. + /// + /// Asked of the manager when there is one to ask (a comparison with this machine, since + /// 2026-10-27 - logic report S03): what it holds is unlisted, and what it does not is removed, whatever + /// the two sides' elevation. Without one - two files - an entry missing from a side read without + /// elevation is "cannot tell", as before: nobody can ask a file's machine any more. + /// + /// Whether the later side was read with fewer rights than the earlier one. + private static List Missing( + List missing, + Func, IReadOnlyList>? held, + bool mayBeUnseen, + List removed, + List uncertain) + { + var stillHeld = held is null || missing.Count == 0 + ? [] + : held([.. missing.Select(entry => entry.ServiceName)]).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var unlisted = new List(); + + foreach (var entry in missing) + { + var into = stillHeld.Contains(entry.ServiceName) ? unlisted + : held is null && mayBeUnseen ? uncertain + : removed; + + into.Add(new EntryPresence(entry.ServiceName, entry.DisplayName)); + } + + return unlisted; + } + /// Fields this machine refused. A fact about permissions on this entry. private static IEnumerable Refused(EntryDocument entry) => - (entry.Unreadable?.Keys ?? Enumerable.Empty()).Select(Camel); + (entry.Unreadable?.Keys ?? Enumerable.Empty()).Select(EntryDocument.Camel); /// Fields nobody asked about on that run. A fact about the run, not the entry. private static IEnumerable NotAsked(EntryDocument entry) => - (entry.NotRead ?? []).Select(Camel); - - /// - /// The field names inside "unreadable" and "notRead" are written the same way as the - /// fields they talk about. They come from the property names, so they arrive capitalised - /// and the keys beside them do not. - /// - private static string Camel(string name) => - name.Length == 0 ? name : char.ToLowerInvariant(name[0]) + name[1..]; + (entry.NotRead ?? []).Select(EntryDocument.Camel); /// /// What a comparison could not look at and says once rather than against each entry - fields diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs b/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs new file mode 100644 index 00000000..7ade392b --- /dev/null +++ b/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs @@ -0,0 +1,199 @@ +using System.Text.Json.Nodes; + +namespace Bws.Core.Snapshots; + +/// +/// How two values of one field are compared - which lists ignore order and spelling, which names ignore +/// case, and since 2026-10-27 how a launch command is split into the path that ignores case and the +/// arguments that do not (logic report R06, owner's decision "the path without case, the arguments with it"). +/// +/// Its own file since that day, when the launch command joined and the comparison stood over the share +/// of its file ceiling the size guard counts as near. One subject - the form a value is compared in - so one +/// place. +/// +/// Until R06 the whole command was compared without case, on the argument of backlog 249 that Windows +/// paths ignore case. True of the path, and silent about the rest: the arguments are read by the program the +/// service runs, and --tenant=ACME becoming --tenant=acme may be a different tenant. The +/// comparison called that no change, and --exit-code ended with nought. +/// +public sealed partial record SnapshotDiff +{ + /// + /// Fields whose value is a list where neither the order nor the spelling carries meaning. + /// + /// Three 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 + /// them, and the spelling varies between services on ONE machine: Schedule declares + /// SeSystemTimePrivilege and Sense declares SeSystemtimePrivilege, and they are the same + /// privilege. has said since it was written that + /// comparison is case-insensitive everywhere and that a diff comparing them as plain text + /// would report a change between two machines that had none. It did exactly that until + /// 2026-08-26. + /// + /// dependsOn - service names, which Windows itself compares without case (`ADR-14`), + /// 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. + /// + /// 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 + /// nowhere. IT WAS MISSED HERE ON THE DAY IT WAS ADDED and the guard below did not say so, + /// because it read a rendered specimen and this field is unread in every one - so a list field + /// that happened to be null was not an array and was invisible to the check whose entire + /// subject is a field nobody came back for. That guard asks the TYPE now. + /// + /// + /// Internal rather than private so that a guard can check it covers every list in the + /// document. The way this list goes wrong is not that a name in it is wrong - it is that a + /// field is ADDED to the document and nobody comes here, which brings order-sensitivity back + /// for that one field, silently, on a surface whose whole job is telling real drift from noise. + /// + internal static readonly string[] Unordered = ["dependsOn", "requiredBy", "requiredPrivileges", "triggers"]; + + /// + /// Fields whose value names something Windows itself compares without case. + /// + /// Three fields, four from 2026-08-26 until 2026-10-27, and each one is here because the + /// SYSTEM says so rather than because looser felt safer. + /// + /// account - an account is a SID, and the name is the translated label on it. `ADR-14` + /// is the whole of that argument: identity travels by identifier and the name is for showing a + /// person. LocalSystem and localsystem are one account. + /// + /// binaryFile - Windows paths are case-insensitive, so two spellings of one path run one + /// file. A snapshot reporting drift because somebody retyped a path in different capitals would + /// be reporting a change that changed nothing about what runs. binaryPath left this list + /// on 2026-10-27 (logic report R06): it holds the arguments too, and only its path ignores case + /// - see . + /// + /// loadOrderGroup - a group name is matched by the manager without case, the same way a + /// service name is. + /// + /// What is deliberately NOT here, and it is the half that matters: binaryHash, + /// securityDescriptor, displayName and description. A hash differing in + /// case is a different hash. An SDDL string is read character for character by the system that + /// parses it. And the two human-facing names are TEXT rather than identifiers - somebody + /// recapitalising a display name really did change what an administrator will read. + /// + private static readonly string[] TheSystemIgnoresCase = + ["account", "binaryFile", "loadOrderGroup"]; + + /// + /// A value in the form the comparison uses, which is NOT always the form a person is shown. + /// + /// The two were one representation until 2026-08-26, and splitting them was deliberate + /// rather than a convenience. The old sentence here said that keeping one form meant what + /// the comparison decided and what somebody is shown could never disagree - a real property, + /// and it was paid for with a false difference on every list whose order or spelling moved + /// without anything changing. A tool whose whole subject is telling real drift from noise may + /// not report drift that is not there. + /// + /// So the split goes one way only. This form exists to decide whether two values differ + /// and is never shown to anybody. What a person reads is still of the value + /// as it was written - original spelling, original order - so a difference that IS reported + /// still shows both sides exactly as the two snapshots hold them. + /// + /// Everything not named in is compared exactly as before. That is the + /// half of this that is easy to get wrong: comparing every field without case would quietly + /// stop binaryHash and sddl from reporting changes that are real. + /// + private static string? Compared(string field, JsonNode? node) + { + if (node is JsonArray items && Unordered.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. + return string.Join( + '\u001f', + items + .Select(item => (Text(item) ?? string.Empty).ToLowerInvariant()) + .OrderBy(text => text, StringComparer.Ordinal)); + } + + if (field == Command) + { + return Text(node) is { } command ? CommandKey(command) : null; + } + + return TheSystemIgnoresCase.Contains(field, StringComparer.Ordinal) + ? Text(node)?.ToLowerInvariant() + : Text(node); + } + + /// + /// A value as text, for showing, and for comparing everything does not + /// take a view on. + /// + /// Strings come out as themselves rather than quoted, because a quoted path in a table reads + /// like a mistake. + /// + private static string? Text(JsonNode? node) => node switch + { + null => null, + JsonValue value when value.TryGetValue(out var text) => text, + _ => node.ToJsonString() + }; + + /// The one field whose value is a path followed by arguments. + private const string Command = "binaryPath"; + + /// + /// A launch command in the form the comparison uses: the part naming the file without case, the rest + /// exactly as written. + /// + internal static string CommandKey(string command) + { + var end = FileEnds(command); + + return command[..end].ToLowerInvariant() + command[end..]; + } + + /// + /// Where the file a launch command names ends, read from the text alone. + /// + /// Without the disk, which is the difference from : the two + /// commands come from two snapshots, and the earlier one may describe another machine. So an unquoted + /// command with spaces - where only the disk can say which prefix is the file - is cut where the first + /// prefix ends in a name Windows runs, and with no such prefix at the first space. The price, said: + /// a path with a space and no extension compares what follows the space exactly, so a change of case + /// there is reported, with both spellings shown. A difference shown that a person can dismiss costs less + /// than one hidden in an audit. + /// + /// Quotes settle it, as they do for Windows. A command with a quote opened and never closed is all file + /// name - the resolver reads it the same way. + /// + internal static int FileEnds(string command) + { + var start = command.Length - command.TrimStart().Length; + + if (start == command.Length) + { + return start; + } + + if (command[start] == '"') + { + var close = command.IndexOf('"', start + 1); + + return close < 0 ? command.Length : close + 1; + } + + int? firstSpace = null; + + for (var space = command.IndexOf(' ', start); space >= 0; space = command.IndexOf(' ', space + 1)) + { + if (BinaryPathResolver.HasExecutableExtension(command[start..space])) + { + return space; + } + + firstSpace ??= space; + } + + return BinaryPathResolver.HasExecutableExtension(command[start..]) ? command.Length : firstSpace ?? command.Length; + } +} diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.cs b/src/Bws.Core/Snapshots/SnapshotDiff.cs index 016b6c61..7e01394c 100644 --- a/src/Bws.Core/Snapshots/SnapshotDiff.cs +++ b/src/Bws.Core/Snapshots/SnapshotDiff.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Nodes; namespace Bws.Core.Snapshots; @@ -108,6 +107,13 @@ public sealed record RefusedField(string Field, IReadOnlyList Entries); /// The per-user session copies neither side was compared on - see . /// Matched by the role the type bits give an entry, never by the shape of its name. /// +/// +/// Entries the snapshot holds that this machine's listing did not, and that the manager, asked by name, +/// says it still holds - only a comparison with this machine fills it (, +/// logic report S03, 2026-10-27). Not drift and not removed: the listing leaves out what this account +/// may not query. Their own key at the end of the machine-readable comparison, "unlisted" - owner's +/// decision of the same day (backlog 623), so a script tells them from "cannot tell" as a person does. +/// public sealed partial record SnapshotDiff( IReadOnlyList Added, IReadOnlyList Removed, @@ -117,7 +123,8 @@ public sealed partial record SnapshotDiff( IReadOnlyList RefusedByBoth, IReadOnlyList Uncertain, ComparisonCaveats Caveats, - InstancesLeftOut LeftOut) + InstancesLeftOut LeftOut, + IReadOnlyList Unlisted) { /// Identity, not a value - it is how the two sides are matched at all. private const string Identity = "serviceName"; @@ -168,7 +175,8 @@ public sealed partial record SnapshotDiff( /// something one side could not see, or something one side never read. /// public bool Reported => - Added.Count > 0 || Removed.Count > 0 || Changed.Count > 0 || Uncertain.Count > 0 || NotFullyCompared.Count > 0; + Added.Count > 0 || Removed.Count > 0 || Changed.Count > 0 || Uncertain.Count > 0 || NotFullyCompared.Count > 0 + || Unlisted.Count > 0; /// /// Compares two snapshots, or says why one of them is not something to compare. @@ -197,7 +205,36 @@ public sealed partial record SnapshotDiff( /// switches off the check that would notice the day this stops being true. /// public static bool TryBetween( - Snapshot before, Snapshot after, [NotNullWhen(true)] out SnapshotDiff? diff, out string? failure) + Snapshot before, Snapshot after, [NotNullWhen(true)] out SnapshotDiff? diff, out string? failure) => + TryBetween(before, after, held: null, out diff, out failure); + + /// + /// Compares a snapshot with this machine read now, asking the manager by name about every entry the + /// snapshot holds and the reading does not - since 2026-10-27, logic report S03. + /// + /// Until then a missing entry was removed unless the two sides differed in elevation, and the + /// listing is not the manager's word on what exists: it leaves out, without an error, what this account + /// may not query. An entry whose permissions changed between the two readings came back as removed, + /// and --exit-code called it drift. Asked by name, the manager settles it both ways - an entry it + /// holds is , and one it does not is removed even across a difference in + /// elevation, where it used to be "cannot tell". + /// + /// The manager the later side was read from, asked after it was read. + /// The manager could not be opened to ask. + public static bool TryAgainstMachine( + Snapshot before, Snapshot now, IScmCatalog machine, [NotNullWhen(true)] out SnapshotDiff? diff, out string? failure) + { + ArgumentNullException.ThrowIfNull(machine); + + return TryBetween(before, now, machine.ReadHeld, out diff, out failure); + } + + private static bool TryBetween( + Snapshot before, + Snapshot after, + Func, IReadOnlyList>? held, + [NotNullWhen(true)] out SnapshotDiff? diff, + out string? failure) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); @@ -212,7 +249,7 @@ public static bool TryBetween( return false; } - diff = Between(before, after); + diff = Between(before, after, held); return true; } @@ -237,7 +274,8 @@ private static bool Unusable(Snapshot snapshot, string side, out string? failure return false; } - private static SnapshotDiff Between(Snapshot before, Snapshot after) + private static SnapshotDiff Between( + Snapshot before, Snapshot after, Func, IReadOnlyList>? held) { var caveats = ComparisonCaveats.Between(before.Metadata, after.Metadata); var elevationDiffers = caveats.ElevationDiffers; @@ -285,17 +323,12 @@ private static SnapshotDiff Between(Snapshot before, Snapshot after) } } - foreach (var entry in Ordered(earlier).Where(entry => !right.ContainsKey(entry.ServiceName))) - { - if (Invisible(elevationDiffers, blind: after.Metadata.Elevated)) - { - uncertain.Add(Presence(entry)); - } - else - { - removed.Add(Presence(entry)); - } - } + var unlisted = Missing( + [.. Ordered(earlier).Where(entry => !right.ContainsKey(entry.ServiceName))], + held, + Invisible(elevationDiffers, blind: after.Metadata.Elevated), + removed, + uncertain); return new SnapshotDiff( added, @@ -306,7 +339,8 @@ [.. unseen.NeitherRead.OrderBy(field => field, StringComparer.Ordinal)], [.. unseen.RefusedByBoth.Select(pair => new RefusedField(pair.Key, pair.Value))], [.. uncertain.OrderBy(entry => entry.ServiceName, StringComparer.Ordinal)], caveats, - new InstancesLeftOut(leftOutEarlier, leftOutLater)); + new InstancesLeftOut(leftOutEarlier, leftOutLater), + unlisted); } /// @@ -336,8 +370,14 @@ private static List Kept(IReadOnlyList entries, ou /// /// Only in one direction, and the direction is the whole point. An unelevated snapshot is /// a subset of an elevated one, so a row the unelevated side lacks may simply be - /// invisible - but a row the *elevated* side lacks is genuinely gone, because anything + /// invisible. A row the *elevated* side lacks was called genuinely gone here, because anything /// the restricted view could see the full one could see too. + /// + /// That last sentence is not true, and since 2026-10-27 it says so (logic report S03): the + /// listing leaves out whatever this account may not query, elevated or not, and an entry whose + /// permissions changed between two readings at the same elevation is missing from the second. Two + /// files cannot settle it - nobody can ask a file's machine - so for them this rule stands, said. + /// A comparison with this machine asks the manager instead (). /// private static bool Invisible(bool elevationDiffers, bool blind) => elevationDiffers && !blind; @@ -424,119 +464,6 @@ private static List Kept(IReadOnlyList entries, ou private static bool Comparable(string field) => field != Identity && field != Unstable && !Bookkeeping.Contains(field, StringComparer.Ordinal); - /// - /// Fields whose value is a list where neither the order nor the spelling carries meaning. - /// - /// Three 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 - /// them, and the spelling varies between services on ONE machine: Schedule declares - /// SeSystemTimePrivilege and Sense declares SeSystemtimePrivilege, and they are the same - /// privilege. has said since it was written that - /// comparison is case-insensitive everywhere and that a diff comparing them as plain text - /// would report a change between two machines that had none. It did exactly that until - /// 2026-08-26. - /// - /// dependsOn - service names, which Windows itself compares without case (`ADR-14`), - /// 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. - /// - /// 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 - /// nowhere. IT WAS MISSED HERE ON THE DAY IT WAS ADDED and the guard below did not say so, - /// because it read a rendered specimen and this field is unread in every one - so a list field - /// that happened to be null was not an array and was invisible to the check whose entire - /// subject is a field nobody came back for. That guard asks the TYPE now. - /// - /// - /// Internal rather than private so that a guard can check it covers every list in the - /// document. The way this list goes wrong is not that a name in it is wrong - it is that a - /// field is ADDED to the document and nobody comes here, which brings order-sensitivity back - /// for that one field, silently, on a surface whose whole job is telling real drift from noise. - /// - internal static readonly string[] Unordered = ["dependsOn", "requiredBy", "requiredPrivileges", "triggers"]; - - /// - /// Fields whose value names something Windows itself compares without case. - /// - /// Four fields, added 2026-08-26, and each one is here because the SYSTEM says so rather - /// than because looser felt safer. - /// - /// account - an account is a SID, and the name is the translated label on it. `ADR-14` - /// is the whole of that argument: identity travels by identifier and the name is for showing a - /// person. LocalSystem and localsystem are one account. - /// - /// binaryPath, binaryFile - Windows paths are case-insensitive, so two spellings - /// of one path run one file. A snapshot reporting drift because somebody retyped a path in - /// different capitals would be reporting a change that changed nothing about what runs. - /// - /// loadOrderGroup - a group name is matched by the manager without case, the same way a - /// service name is. - /// - /// What is deliberately NOT here, and it is the half that matters: binaryHash, - /// securityDescriptor, displayName and description. A hash differing in - /// case is a different hash. An SDDL string is read character for character by the system that - /// parses it. And the two human-facing names are TEXT rather than identifiers - somebody - /// recapitalising a display name really did change what an administrator will read. - /// - private static readonly string[] TheSystemIgnoresCase = - ["account", "binaryPath", "binaryFile", "loadOrderGroup"]; - - /// - /// A value in the form the comparison uses, which is NOT always the form a person is shown. - /// - /// The two were one representation until 2026-08-26, and splitting them was deliberate - /// rather than a convenience. The old sentence here said that keeping one form meant what - /// the comparison decided and what somebody is shown could never disagree - a real property, - /// and it was paid for with a false difference on every list whose order or spelling moved - /// without anything changing. A tool whose whole subject is telling real drift from noise may - /// not report drift that is not there. - /// - /// So the split goes one way only. This form exists to decide whether two values differ - /// and is never shown to anybody. What a person reads is still of the value - /// as it was written - original spelling, original order - so a difference that IS reported - /// still shows both sides exactly as the two snapshots hold them. - /// - /// Everything not named in is compared exactly as before. That is the - /// half of this that is easy to get wrong: comparing every field without case would quietly - /// stop binaryHash and sddl from reporting changes that are real. - /// - private static string? Compared(string field, JsonNode? node) - { - if (node is JsonArray items && Unordered.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. - return string.Join( - '\u001f', - items - .Select(item => (Text(item) ?? string.Empty).ToLowerInvariant()) - .OrderBy(text => text, StringComparer.Ordinal)); - } - - return TheSystemIgnoresCase.Contains(field, StringComparer.Ordinal) - ? Text(node)?.ToLowerInvariant() - : Text(node); - } - - /// - /// A value as text, for showing, and for comparing everything does not - /// take a view on. - /// - /// Strings come out as themselves rather than quoted, because a quoted path in a table reads - /// like a mistake. - /// - private static string? Text(JsonNode? node) => node switch - { - null => null, - JsonValue value when value.TryGetValue(out var text) => text, - _ => node.ToJsonString() - }; - private static EntryPresence Presence(EntryDocument entry) => new(entry.ServiceName, entry.DisplayName); diff --git a/src/Bws.Core/Snapshots/SnapshotOnDisk.cs b/src/Bws.Core/Snapshots/SnapshotOnDisk.cs index 6ccd6e07..e0646e18 100644 --- a/src/Bws.Core/Snapshots/SnapshotOnDisk.cs +++ b/src/Bws.Core/Snapshots/SnapshotOnDisk.cs @@ -26,7 +26,13 @@ public enum DiskFault IsFolder, /// A snapshot stands there, and nobody said it may be replaced. - AlreadyThere + AlreadyThere, + + /// + /// The snapshot about to be written is larger than , so this build + /// could not read it back - written nowhere. Since 2026-10-27, logic report S02. + /// + WouldBeTooBig } /// @@ -143,6 +149,65 @@ public static string Target(string given, SnapshotMetadata metadata) return null; } + /// + /// Writes a snapshot's text to the path, or says why not and writes nothing - the one way a snapshot + /// reaches a disk, for the command line and the window alike. + /// + /// Since 2026-10-27, logic report R11 and S02, and both were the same shape: a promise made by a + /// question asked earlier and not kept by the write. The size this build reads back was checked only + /// on files already there, so a snapshot over it was written, announced, and then refused by the same + /// build as "not a snapshot this build wrote". And "nothing replaces a file without being told to" was + /// true until the move, which always replaced. Both are now asked of the write itself, in this order: + /// the size of the text (nothing else is worth asking about a file that may not be written at all), + /// the rule about what stands there, and then a move that may not replace unless told it may. + /// + /// See . + /// The directory does not exist. + public static DiskRefusal? Write(string target, string text, bool force) => Write(target, text, force, Biggest); + + /// + /// everywhere but in a test, which cannot afford sixty four megabytes of text to + /// show the refusal. + /// + /// + /// What happens between the question about the target and the move - nothing, everywhere but in a + /// test, which puts a file there to show the move refusing it. The gap is microseconds wide and R11 + /// was about exactly it, so without this nothing could tell a move that refuses from one that does not. + /// + internal static DiskRefusal? Write(string target, string text, bool force, long ceiling, Action? meanwhile = null) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(text); + + // In the bytes AtomicFile writes - UTF-8 without a mark - because that is the length the reader + // compares with the same ceiling. + if (Encoding.UTF8.GetByteCount(text) > ceiling) + { + return new DiskRefusal(DiskFault.WouldBeTooBig, target, string.Empty); + } + + if (WhyNotWritten(target, force) is { } refusal) + { + return refusal; + } + + meanwhile?.Invoke(); + + if (force) + { + AtomicFile.Write(target, text); + + return null; + } + + // Something arrived between the question above and the move. Asked again to name it - a folder, a + // snapshot, another program's file - and called a snapshot standing there when it went again + // before it could be named, which is still the truth about the moment the move was refused. + return AtomicFile.WriteNew(target, text) + ? null + : WhyNotWritten(target, force: false) ?? new DiskRefusal(DiskFault.AlreadyThere, target, string.Empty); + } + /// /// Reads one snapshot from disk, or says what is wrong with what was pointed at. /// @@ -171,15 +236,16 @@ public static bool TryLoad( // ASKED HOW BIG IT IS BEFORE READING IT ALL, SINCE 2026-09-03 - backlog 308(c). The reader // below builds one string out of every byte, so a path pointing at something enormous ends // the process with an out of memory failure. A file that is not there falls through to the - // reader, which produces the sentence about a path somebody got wrong. - if (File.Exists(path) && new FileInfo(path).Length > Biggest) + // reader, which produces the sentence about a path somebody got wrong. Asked first of the + // path, which needs no handle - so a file too big is refused as too big even while something + // holds it - and AGAIN of the file once it is open, since 2026-10-27 (logic report S02), + // because the answer about the path is about a file that can change before it is opened. + if ((File.Exists(path) && new FileInfo(path).Length > Biggest) || !TryReadText(path, out content)) { refusal = new DiskRefusal(DiskFault.TooBig, path, string.Empty); return false; } - - content = ReadText(path); } // AHEAD OF THE TWO BELOW, and it has to be: a decoder failure IS an ArgumentException, so the @@ -235,12 +301,15 @@ public static bool TryLoad( { try { - if (new FileInfo(path).Length > Biggest) + // Of the open file only, since 2026-10-27 (logic report S02): asked of the path as well, the two + // would be two gates for one rule here, and no mutation could prove either. TryLoad keeps both, + // because there the path answers a question the open file cannot - a file held by somebody else. + if (!TryReadText(path, out var content)) { return new DiskRefusal(DiskFault.TooBig, path, string.Empty); } - return SnapshotJson.TryRead(ReadText(path), out _, out var failure) + return SnapshotJson.TryRead(content, out _, out var failure) ? null : new DiskRefusal(DiskFault.NotASnapshot, path, failure ?? string.Empty); } @@ -275,16 +344,67 @@ public static bool TryLoad( /// Two hundred and ninety seven entries reported as changed, and not one of them had. The /// mark is still honoured, so a snapshot saved as UTF-16 by something else still reads. Only bytes /// that are not any of those come back as a refusal. + /// + /// THE MARK IS READ HERE, AND UNTIL 2026-10-27 THE READER READ IT - logic report S01, measured + /// 2026-10-26 on .NET 10.0.13. A reader told to honour marks swaps the decoder it was handed for + /// the default one of the encoding it found, and the default ones substitute: a file WITHOUT a mark + /// and a broken byte was refused, the same file WITH a UTF-8 mark came back with U+FFFD inside a + /// value, and so did UTF-16 with a mark and a lone surrogate. Now the mark only chooses which strict + /// decoder reads the rest, so a broken byte is refused whatever stands in front of it. + /// + /// The size is asked of the file once it is open as well, since the same day (S02) - the + /// callers' question about the path is a question about a file that could change before it was + /// opened. Opened for reading with others allowed only to read, so nothing grows it while it is read. /// + /// False, with no text, when the file is larger than . /// The bytes are not text this build can read. - private static string ReadText(string path) + /// The path is not a file on a disk - a device or a pipe has no length. + private static bool TryReadText(string path, out string text) { + text = string.Empty; + using var stream = File.OpenRead(path); - using var reader = new StreamReader( - stream, - new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), - detectEncodingFromByteOrderMarks: true); - return reader.ReadToEnd(); + if (!stream.CanSeek) + { + throw new IOException("It is not a file on a disk, so it was not read."); + } + + if (stream.Length > Biggest) + { + return false; + } + + using var reader = new StreamReader(stream, Strict(stream), detectEncodingFromByteOrderMarks: false); + + text = reader.ReadToEnd(); + + return true; + } + + /// + /// The decoder for what the first bytes say the file is, refusing rather than substituting, with the + /// stream left just past the mark. The five marks a reader recognises by itself, in its order - + /// UTF-32 little endian before UTF-16 little endian, because the first begins with the second - and + /// UTF-8 for a file with none, which is what this build writes. + /// + private static Encoding Strict(Stream stream) + { + Span head = stackalloc byte[4]; + var read = stream.ReadAtLeast(head, head.Length, throwOnEndOfStream: false); + + var (encoding, mark) = head[..read] switch + { + [0xFF, 0xFE, 0x00, 0x00] => ((Encoding)new UTF32Encoding(bigEndian: false, byteOrderMark: false, throwOnInvalidCharacters: true), 4), + [0x00, 0x00, 0xFE, 0xFF] => (new UTF32Encoding(bigEndian: true, byteOrderMark: false, throwOnInvalidCharacters: true), 4), + [0xEF, 0xBB, 0xBF, ..] => (new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), 3), + [0xFF, 0xFE, ..] => (new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: true), 2), + [0xFE, 0xFF, ..] => (new UnicodeEncoding(bigEndian: true, byteOrderMark: false, throwOnInvalidBytes: true), 2), + _ => (new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), 0) + }; + + stream.Position = mark; + + return encoding; } } diff --git a/src/Bws.Core/WindowsScmCatalog.Held.cs b/src/Bws.Core/WindowsScmCatalog.Held.cs new file mode 100644 index 00000000..e37427b2 --- /dev/null +++ b/src/Bws.Core/WindowsScmCatalog.Held.cs @@ -0,0 +1,68 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Windows.Win32; + +namespace Bws.Core; + +/// +/// Which names the manager still holds - since 2026-10-27, logic report S03. +/// +/// The manager is asked by name and the registry is not read, unlike the question R04 asks before a +/// process is ended (WindowsEndingFactsReader.Unlisted.cs). There the names are what is missing, and only the +/// registry hands them out. Here a snapshot already names them, and the services API answers a question +/// about a name - so the rule to ask that API wherever it answers holds. +/// +public sealed partial class WindowsScmCatalog +{ + /// + public IReadOnlyList ReadHeld(IReadOnlyList names) + { + ArgumentNullException.ThrowIfNull(names); + + // Nothing missing is the ordinary answer, and it opens nothing. + if (names.Count == 0) + { + return []; + } + + using var manager = PInvoke.OpenSCManager( + lpMachineName: null!, + lpDatabaseName: null!, + dwDesiredAccess: PInvoke.SC_MANAGER_CONNECT); + + if (manager.IsInvalid) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Could not open the service control manager to ask about entries missing from its list."); + } + + // ONE NAME AT A TIME ON PURPOSE, measured rather than copied from ReadAll, which is parallel because + // describing an entry costs about a millisecond. Asking whether a name is held costs one open and one + // close: measured 2026-10-27 (16 processors, elevated, five runs after a cold one) at 31.0-33.5 ms for + // 781 real names one at a time against 16.5-17.4 ms spread over the processors, and at 13.3-13.6 ms + // for 781 names that do not exist either way. The ordinary answer is a handful of names, so threads + // would buy nothing a person can see, and they would not shorten a hostile file full of invented + // names at all - those answer in one call each, as fast in a row as spread out. + return [.. names.Where(name => Held(manager, name))]; + } + + /// + /// Whether the manager holds this name: it opened, or it refused for any reason but "no such entry" - + /// the same reading of a refusal as , which this asks. + /// + /// A name holding a NUL is not asked: the names come from a file, and passed to Windows such a + /// name ends at the NUL - so "Spooler" followed by anything would be answered about Spooler. + /// + private static bool Held(SafeHandle manager, string name) + { + if (name.Contains('\0', StringComparison.Ordinal)) + { + return false; + } + + using var service = PInvoke.OpenService(manager, name, PInvoke.SERVICE_QUERY_STATUS); + + return !service.IsInvalid || WindowsEndingFactsReader.Withholds(Marshal.GetLastWin32Error()); + } +} diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index bef32e33..790a0e4d 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -505,12 +505,14 @@ "gui.snapshot.readFailed": "This machine could not be read. {0}", "gui.snapshot.writeFailed": "The snapshot could not be written. {0}", "gui.snapshot.cannotCompare": "Nothing was compared. {0}", + "gui.snapshot.movedOn": "The comparison finished after the window had moved on to something else, so it was not shown. Start it again to see it.", "gui.snapshot.fault.unreadable": "{0} could not be read. {1}", "gui.snapshot.fault.tooBig": "{0} is larger than {1} MB, so it is not a snapshot this tool wrote. A snapshot of a whole machine is about one megabyte.", "gui.snapshot.fault.notUtf8": "{0} is not UTF-8, and this will not guess at the bytes. A snapshot is written as UTF-8, so the file was re-encoded somewhere.", "gui.snapshot.fault.notASnapshot": "{0} is not a snapshot this version can read. {1}", "gui.snapshot.fault.isFolder": "{0} is a folder, so no snapshot was written there.", "gui.snapshot.fault.alreadyThere": "{0} is already a snapshot, and nobody said it may be replaced.", + "gui.snapshot.fault.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 the note is long, shorten it.", "gui.snapshot.fault.notReplaced": "{0} No snapshot was written over it, and it was left as it was.", "gui.snapshot.chooseForMachine": "Choose a snapshot to compare with this machine", "gui.snapshot.chooseFirst": "Choose the first of two snapshots to compare", @@ -540,6 +542,8 @@ "gui.diff.row.added": "Added since the snapshot", "gui.diff.row.removed": "Gone since the snapshot", "gui.diff.row.uncertain": "Seen on one side only - the other side was read without administrator rights", + "gui.diff.row.unlisted": "Still there - Windows still holds it, but leaves it out of the list it shows this account", + "gui.diff.row.notListed": "there, not listed", "gui.diff.row.there": "there", "gui.diff.row.notThere": "not there", "gui.diff.row.cannotTell": "cannot tell", @@ -554,7 +558,7 @@ "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.notKnown": "One of the two sides does not record these, so they could not be checked: {0}", - "gui.diff.caveat.neitherRead": "Neither side holds these fields, so no entry was compared on them: {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}.", "gui.diff.caveat.refusedByBoth.one": "Windows would not give {0} to either side for {1} entry, so it was not compared on it: {2}", "gui.diff.caveat.refusedByBoth.many": "Windows would not give {0} to either side for {1} entries, so they were not compared on it: {2}", diff --git a/src/Bws.Gui/ViewModels/Catalogue.Machines.cs b/src/Bws.Gui/ViewModels/Catalogue.Machines.cs index af0b22b6..8eeb3ca4 100644 --- a/src/Bws.Gui/ViewModels/Catalogue.Machines.cs +++ b/src/Bws.Gui/ViewModels/Catalogue.Machines.cs @@ -37,6 +37,10 @@ public IReadOnlyList ReadStatuses() => // which is the plan that is easiest to read on a sheet. public Reading> ReadDependents(string serviceName) => Reading>.Present([]); + + // Everything it holds it also lists, so a name missing from the listing is gone. + public IReadOnlyList ReadHeld(IReadOnlyList names) => + [.. names.Where(name => entries.Any(entry => string.Equals(entry.ServiceName, name, StringComparison.OrdinalIgnoreCase)))]; } /// @@ -53,6 +57,8 @@ private sealed class Refusing : IScmCatalog public Reading> ReadDependents(string serviceName) => Reading>.Denied(AccessDenied, new System.ComponentModel.Win32Exception(AccessDenied).Message); + + public IReadOnlyList ReadHeld(IReadOnlyList names) => throw new System.ComponentModel.Win32Exception(AccessDenied); } /// @@ -77,6 +83,9 @@ public IReadOnlyList ReadStatuses() public Reading> ReadDependents(string serviceName) => Reading>.NotRead(); + + // Asked only after a listing, which this machine never finishes - so never asked. + public IReadOnlyList ReadHeld(IReadOnlyList names) => []; } /// diff --git a/src/Bws.Gui/ViewModels/DiffLines.cs b/src/Bws.Gui/ViewModels/DiffLines.cs index fc4ebd27..131aabcd 100644 --- a/src/Bws.Gui/ViewModels/DiffLines.cs +++ b/src/Bws.Gui/ViewModels/DiffLines.cs @@ -65,6 +65,11 @@ .. diff.Removed.Select(entry => Presence( // different sentences, and only one of them is a fact. .. diff.Uncertain.Select(entry => Presence( entry, DiffLineKind.Uncertain, Texts.Of("gui.diff.row.uncertain"), Texts.Of("gui.diff.row.cannotTell"), Texts.Of("gui.diff.row.cannotTell"))), + + // Seen on one side only too, and filtered with those - but the manager says it still holds these, + // so the row says so rather than "cannot tell" (logic report S03, 2026-10-27). + .. diff.Unlisted.Select(entry => Presence( + entry, DiffLineKind.Uncertain, Texts.Of("gui.diff.row.unlisted"), Texts.Of("gui.diff.row.there"), Texts.Of("gui.diff.row.notListed"))), .. diff.Changed.Concat(diff.NotFullyCompared).SelectMany(Fields) ]; diff --git a/src/Bws.Gui/ViewModels/SnapshotWords.cs b/src/Bws.Gui/ViewModels/SnapshotWords.cs index 5fdc4ad7..de9a0ad2 100644 --- a/src/Bws.Gui/ViewModels/SnapshotWords.cs +++ b/src/Bws.Gui/ViewModels/SnapshotWords.cs @@ -23,6 +23,7 @@ internal static string Of(DiskRefusal refusal) DiskFault.IsFolder => Texts.Of("gui.snapshot.fault.isFolder", path), DiskFault.AlreadyThere => Texts.Of("gui.snapshot.fault.alreadyThere", path), DiskFault.TooBig => Texts.Of("gui.snapshot.fault.tooBig", path, SnapshotOnDisk.Biggest / (1024 * 1024)), + DiskFault.WouldBeTooBig => Texts.Of("gui.snapshot.fault.wouldBeTooBig", path, SnapshotOnDisk.Biggest / (1024 * 1024)), DiskFault.NotUtf8 => Texts.Of("gui.snapshot.fault.notUtf8", path), DiskFault.NotASnapshot => Texts.Of("gui.snapshot.fault.notASnapshot", path, Printable.Of(refusal.Detail)), // The reason is the message of whatever the reading threw, and .NET quotes the path in @@ -34,10 +35,11 @@ internal static string Of(DiskRefusal refusal) /// /// Why a snapshot was not written where somebody asked - and, when what stands there is not a - /// snapshot, that it was left exactly as it was (S-8). + /// snapshot, that it was left exactly as it was (S-8). A snapshot too large to read back is about + /// the snapshot rather than about anything standing there, so it says so on its own. /// internal static string NotWritten(DiskRefusal refusal) => - refusal.Fault is DiskFault.IsFolder or DiskFault.AlreadyThere + refusal.Fault is DiskFault.IsFolder or DiskFault.AlreadyThere or DiskFault.WouldBeTooBig ? Of(refusal) : Texts.Of("gui.snapshot.fault.notReplaced", Of(refusal)); } diff --git a/src/Bws.Gui/ViewModels/Snapshotting.Turns.cs b/src/Bws.Gui/ViewModels/Snapshotting.Turns.cs new file mode 100644 index 00000000..6319432b --- /dev/null +++ b/src/Bws.Gui/ViewModels/Snapshotting.Turns.cs @@ -0,0 +1,58 @@ +namespace Bws.Gui.ViewModels; + +/// +/// One reading at a time, and whose the middle of the window is when it comes back. +/// +/// A partial since 2026-10-27, when the second half arrived (logic report S05) and Snapshotting.cs +/// stood over the share of its file ceiling the size guard counts as near. Both halves answer the same +/// question about a reading - may it start, and may it show what it found - so they live together. +/// +public sealed partial class Snapshotting +{ + /// + /// How many times somebody changed what stands in the middle of the window - opened the form, put + /// something away, went to another tab. A reading takes the count when it starts and gives the middle + /// its answer only if nobody moved since (logic report S05, 2026-10-27): until then a comparison that + /// came back after somebody had gone to a list took the window back from it. + /// + private int _turn; + + /// + /// Takes the one reading at a time, and says it is under way. + /// + /// No inspector, no snapshot of this machine - asked by the two callers that read it, not + /// here: a model built for a test without one cannot read signatures, and a snapshot without them + /// would compare as though every entry had changed. Two files need no inspector, so they start + /// through here without one. The window always has one - the parameterless MainViewModel hands it + /// the system's - so a missing one is a test's state. + /// + private bool Starting(string saying) + { + if (Busy) + { + return false; + } + + Busy = true; + _says().Did(saying); + + return true; + } + + /// + /// Whether somebody changed the middle of the window since a comparison started - and if so, says at + /// the foot that it finished unseen, so the line about the reading does not stand there with nothing + /// after it. A trouble is said either way: it is about the files, not about the view. + /// + private bool MovedOn(int turn) + { + if (turn == _turn) + { + return false; + } + + _says().Did(Texts.Of("gui.snapshot.movedOn")); + + return true; + } +} diff --git a/src/Bws.Gui/ViewModels/Snapshotting.cs b/src/Bws.Gui/ViewModels/Snapshotting.cs index 291f3250..d40edbdb 100644 --- a/src/Bws.Gui/ViewModels/Snapshotting.cs +++ b/src/Bws.Gui/ViewModels/Snapshotting.cs @@ -96,6 +96,7 @@ internal void Prepare() return; } + _turn++; Shown = null; Form = new SnapshotForm(_says().Elevated); } @@ -122,6 +123,8 @@ internal async Task TakeAsync(string path) return; } + var turn = _turn; + try { // Read here, on the window's thread, before the reading leaves it - the note is what the @@ -145,7 +148,11 @@ internal async Task TakeAsync(string path) // Written, so the form has done its work and the list comes back. A refusal keeps it open // with the note still in it, so another folder is one more press rather than a retyping. - Form = null; + // And a form somebody opened afresh while this one was being read is theirs, not this one's. + if (turn == _turn) + { + Form = null; + } } catch (Win32Exception failure) { @@ -182,6 +189,8 @@ internal async Task CompareAsync(string path) return; } + var turn = _turn; + try { var (earlier, diff, trouble) = await Task.Run(() => Compared(path, inspector)).ConfigureAwait(true); @@ -196,6 +205,11 @@ internal async Task CompareAsync(string path) return; } + if (MovedOn(turn)) + { + return; + } + Shown = DiffSheet.Of(earlier.Metadata, diff); // The line at the foot said what was being read, and the sheet is the answer to it - so @@ -236,6 +250,8 @@ internal async Task CompareFilesAsync(string first, string second) return; } + var turn = _turn; + try { var (earlier, later, diff, trouble) = await Task.Run(() => FilesCompared(first, second)).ConfigureAwait(true); @@ -248,6 +264,11 @@ internal async Task CompareFilesAsync(string first, string second) return; } + if (MovedOn(turn)) + { + return; + } + Shown = DiffSheet.Of(earlier, later, diff); _says().Did(string.Empty); @@ -267,9 +288,15 @@ internal async Task CompareFilesAsync(string first, string second) /// The form goes even while the machine is read for it - the reading finishes and says what /// it wrote at the foot of the window, which is where that sentence goes whether or not the form /// is still up. + /// + /// Counted even when there was nothing to take, because that is what going to another tab + /// looks like while a comparison is still being read: nothing stands in the middle yet, and the + /// comparison must not arrive there afterwards (). /// internal bool PutAway() { + _turn++; + if (Shown is null && Form is null) { return false; @@ -281,28 +308,6 @@ internal bool PutAway() return true; } - /// - /// Takes the one reading at a time, and says it is under way. - /// - /// No inspector, no snapshot of this machine - asked by the two callers that read it, not - /// here: a model built for a test without one cannot read signatures, and a snapshot without them - /// would compare as though every entry had changed. Two files need no inspector, so they start - /// through here without one. The window always has one - the parameterless MainViewModel hands it - /// the system's - so a missing one is a test's state. - /// - private bool Starting(string saying) - { - if (Busy) - { - return false; - } - - Busy = true; - _says().Did(saying); - - return true; - } - /// /// Reads the machine and writes it where somebody asked, or says why not - all of it off the /// interface thread, because asking about a file standing there may read it up to the size @@ -310,8 +315,9 @@ private bool Starting(string saying) /// /// Asked twice, and both are needed. Before the reading, so a target that will be refused /// costs no second of reading the machine - the courtesy the command line extends at the same - /// place. And again right before the write, because a file can appear at the path while the - /// machine is read, and that second ask is the one S-8 stands on. + /// place. And again by the write itself (), because a file can + /// appear at the path while the machine is read, and that second ask is the one S-8 stands on - it + /// also refuses a snapshot too large for this build to read back (logic report S02, 2026-10-27). /// private (Snapshot? Taken, string? Trouble) Written(string path, IBinaryInspector inspector, string? note) { @@ -321,16 +327,10 @@ private bool Starting(string saying) } var read = Read(inspector, note); - var text = SnapshotJson.Render(read); - - if (Refused(path) is { } late) - { - return (null, SnapshotWords.NotWritten(late)); - } - - AtomicFile.Write(path, text); - return (read, null); + return SnapshotOnDisk.Write(path, SnapshotJson.Render(read), force: true) is { } late + ? (null, SnapshotWords.NotWritten(late)) + : (read, null); } /// @@ -360,7 +360,8 @@ private Snapshot Read(IBinaryInspector inspector, string? note) => return (null, null, SnapshotWords.Of(refusal)); } - return SnapshotDiff.TryBetween(earlier, Read(inspector, note: null), out var diff, out var failure) + // Against this machine, so the manager is asked by name about what the reading lacks (logic report S03). + return SnapshotDiff.TryAgainstMachine(earlier, Read(inspector, note: null), _catalog, out var diff, out var failure) ? (earlier, diff, null) : (null, null, Texts.Of("gui.snapshot.cannotCompare", failure ?? string.Empty)); } diff --git a/tests/Bws.Cli.Tests/AsciiDocumentTests.cs b/tests/Bws.Cli.Tests/AsciiDocumentTests.cs index d464c41d..1c9a519b 100644 --- a/tests/Bws.Cli.Tests/AsciiDocumentTests.cs +++ b/tests/Bws.Cli.Tests/AsciiDocumentTests.cs @@ -89,7 +89,8 @@ 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 InstancesLeftOut(0, 0)); + new InstancesLeftOut(0, 0), + [new EntryPresence("Ddd", Hostile)]); var written = DiffJson.Render(diff); diff --git a/tests/Bws.Cli.Tests/DiffOutputTests.cs b/tests/Bws.Cli.Tests/DiffOutputTests.cs index e78e3f49..4ba9b9d1 100644 --- a/tests/Bws.Cli.Tests/DiffOutputTests.cs +++ b/tests/Bws.Cli.Tests/DiffOutputTests.cs @@ -114,6 +114,36 @@ public void A_field_refused_to_both_snapshots_is_one_line_naming_the_entries_and Assert.DoesNotContain(Texts.Of("cli.diff.notFullyCompared", 2), text, StringComparison.Ordinal); } + [Fact] + public void An_entry_the_manager_still_holds_has_its_own_heading_and_is_seen_by_one_side() + { + // Logic report S03: not removed, and not "cannot tell" either - the manager says it holds it. + var diff = Of(changed: []) with { Unlisted = [new EntryPresence("ZTDNS", "ZTDNS display name")] }; + + var text = DiffText.Render(diff); + + Assert.Contains(Texts.Of("cli.diff.unlisted", 1), text, StringComparison.Ordinal); + Assert.Contains(Texts.Of("cli.diff.incomplete.seenByOne.one", 1), text, StringComparison.Ordinal); + Assert.DoesNotContain(Texts.Of("cli.diff.same"), text, StringComparison.Ordinal); + Assert.False(Document(diff).GetProperty("differs").GetBoolean()); + } + + [Fact] + public void A_script_finds_entries_the_manager_still_holds_under_their_own_key_at_the_end() + { + // Backlog 623, owner's decision: their own key, so a script tells "the manager says it is there" + // from "the other side may not have been shown it" - and last, so the document only grew at its end. + var root = Document(Of(changed: []) with + { + Uncertain = [new EntryPresence("Zeta", "Zeta")], + Unlisted = [new EntryPresence("Alpha", "Alpha")] + }); + + Assert.Equal("unlisted", root.EnumerateObject().Last().Name); + Assert.Equal("Alpha", Assert.Single(root.GetProperty("unlisted").EnumerateArray()).GetProperty("serviceName").GetString()); + Assert.Equal("Zeta", Assert.Single(root.GetProperty("uncertain").EnumerateArray()).GetProperty("serviceName").GetString()); + } + [Fact] public void One_entry_refused_a_field_on_both_sides_is_said_in_the_singular() { @@ -131,15 +161,16 @@ public void One_entry_refused_a_field_on_both_sides_is_said_in_the_singular() [Fact] public void A_field_refused_to_both_snapshots_is_written_last_with_its_entries() { - // Last, so a script reading the document by position before 2026-10-14 still finds what it - // found. And the entries are in it by name, so a script loses nothing that the old - // per-entry place under notFullyCompared told it. + // Last when it came, so a script reading the document by position before 2026-10-14 still finds + // what it found - and since 2026-10-27 followed only by "unlisted", which came after it. And the + // entries are in it by name, so a script loses nothing that the old per-entry place under + // notFullyCompared told it. var root = Document(Of(changed: []) with { RefusedByBoth = [new RefusedField("description", ["IsoSessionCore", "Tcpip6"])] }); - Assert.Equal("refusedByBoth", root.EnumerateObject().Last().Name); + Assert.Equal(["refusedByBoth", "unlisted"], root.EnumerateObject().TakeLast(2).Select(property => property.Name)); var refused = Assert.Single(root.GetProperty("refusedByBoth").EnumerateArray()); @@ -152,7 +183,8 @@ private static SnapshotDiff Of(IReadOnlyList changed, InstancesLef new( [], [], changed, [], [], [], [], new ComparisonCaveats(false, false, false, false, false, false, false, []), - leftOut ?? new InstancesLeftOut(0, 0)); + leftOut ?? new InstancesLeftOut(0, 0), + []); private static ChangedEntry Changed(DifferenceGroup group) => new("Spooler", "Print Spooler", [new FieldDifference("status", group, "Running", "Stopped")], []); diff --git a/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs b/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs index 7b3d5810..e1b72f06 100644 --- a/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs +++ b/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs @@ -66,7 +66,8 @@ 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 InstancesLeftOut(0, 0)); + new InstancesLeftOut(0, 0), + []); var written = JsonDocument.Parse(DiffJson.Render(diff)).RootElement .GetProperty("changed")[0].GetProperty("differences").EnumerateArray() diff --git a/tests/Bws.Cli.Tests/SnapshotTargetTests.cs b/tests/Bws.Cli.Tests/SnapshotTargetTests.cs index d36ff304..353abbf0 100644 --- a/tests/Bws.Cli.Tests/SnapshotTargetTests.cs +++ b/tests/Bws.Cli.Tests/SnapshotTargetTests.cs @@ -104,6 +104,36 @@ public void A_snapshot_is_replaced_with_force() Assert.Null(refusal); } + [Fact] + public void A_snapshot_written_where_nothing_stands_reads_back() + { + // The write itself asks the questions since 2026-10-27 (logic report R11, S02) - this is the + // ordinary case it must still let through. + var path = Fresh(); + + Assert.True(SnapshotFiles.TryWrite(path, Snapshot.Of([], note: null, new SystemClock()), force: false, out var refusal), refusal); + Assert.True(SnapshotOnDisk.TryLoad(path, out _, out _)); + } + + [Fact] + public void A_folder_that_is_not_there_is_the_sentence_about_the_path_somebody_typed() + { + // Moved here from Program.cs with the write: the usage code and Windows' own sentence, never a + // failure of the tool. + var path = Path.Combine(Path.GetTempPath(), $"bws-nowhere-{Guid.NewGuid():N}", "snapshot.json"); + + Assert.False(SnapshotFiles.TryWrite(path, Snapshot.Of([], note: null, new SystemClock()), force: false, out var refusal)); + Assert.Contains(Path.GetDirectoryName(path)!, refusal, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A_snapshot_too_large_to_read_back_is_said_on_its_own_and_not_as_a_file_left_alone() + { + Assert.Equal( + Texts.Of("cli.snapshot.wouldBeTooBig", "x.json", 64), + SnapshotFiles.NotWritten(new DiskRefusal(DiskFault.WouldBeTooBig, "x.json", string.Empty))); + } + /// A snapshot this build reads, of no entries, at a fresh path. private string Snapshotted() { diff --git a/tests/Bws.Cli.Tests/TerminalTextTests.cs b/tests/Bws.Cli.Tests/TerminalTextTests.cs index 47ab3a08..9336aab6 100644 --- a/tests/Bws.Cli.Tests/TerminalTextTests.cs +++ b/tests/Bws.Cli.Tests/TerminalTextTests.cs @@ -89,7 +89,8 @@ [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 InstancesLeftOut(0, 0)); + new InstancesLeftOut(0, 0), + [new EntryPresence("Ddd", Hostile)]); Clean(DiffText.Render(diff)); } diff --git a/tests/Bws.Core.Tests/AtomicFileTests.cs b/tests/Bws.Core.Tests/AtomicFileTests.cs index 4cabb476..7cd21462 100644 --- a/tests/Bws.Core.Tests/AtomicFileTests.cs +++ b/tests/Bws.Core.Tests/AtomicFileTests.cs @@ -72,6 +72,43 @@ public void The_file_is_utf8_without_a_mark_and_with_unix_line_endings() Assert.DoesNotContain((byte)'\r', bytes); } + [Fact] + public void A_new_file_lands_where_nothing_stands() + { + var path = Path.Combine(_directory, "snapshot.json"); + + Assert.True(AtomicFile.WriteNew(path, "content")); + + Assert.Equal("content", File.ReadAllText(path)); + Assert.Single(Directory.GetFileSystemEntries(_directory)); + } + + [Fact] + public void A_new_file_leaves_whatever_got_there_first_and_nothing_beside_it() + { + // Logic report R11: two runs writing one name both asked "is anything there?" and both heard + // no, and the second move replaced the first. The refusal lives in the move now. + var path = Path.Combine(_directory, "snapshot.json"); + File.WriteAllText(path, "first"); + + Assert.False(AtomicFile.WriteNew(path, "second")); + + Assert.Equal("first", File.ReadAllText(path)); + Assert.Single(Directory.GetFileSystemEntries(_directory)); + } + + [Fact] + public void A_new_file_is_not_moved_onto_a_folder_that_got_there_first() + { + var path = Path.Combine(_directory, "snapshot.json"); + Directory.CreateDirectory(path); + + Assert.False(AtomicFile.WriteNew(path, "content")); + + Assert.True(Directory.Exists(path)); + Assert.Single(Directory.GetFileSystemEntries(_directory)); + } + [Fact] public void A_directory_that_does_not_exist_is_an_error_and_not_an_invitation() { diff --git a/tests/Bws.Core.Tests/Fakes/FakeScmCatalog.cs b/tests/Bws.Core.Tests/Fakes/FakeScmCatalog.cs index c0b18e58..1d1d4867 100644 --- a/tests/Bws.Core.Tests/Fakes/FakeScmCatalog.cs +++ b/tests/Bws.Core.Tests/Fakes/FakeScmCatalog.cs @@ -70,6 +70,32 @@ public IReadOnlyList ReadStatuses() return [.. _entries.Select(entry => new ScmStatus(entry.ServiceName, entry.Status, entry.ProcessId))]; } + /// + /// Names the manager holds and leaves out of - an entry this account may not + /// query, which the enumeration omits without an error (logic report S03). + /// + internal HashSet HeldUnlisted { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// Every list of names the double was asked whether it holds, in order. + internal List> HeldAsked { get; } = []; + + /// Thrown by the next question about held names, for a manager that will not open. + internal Exception? FailHeld { get; set; } + + public IReadOnlyList ReadHeld(IReadOnlyList names) + { + HeldAsked.Add(names); + + if (FailHeld is { } failure) + { + throw failure; + } + + return [.. names.Where(name => + HeldUnlisted.Contains(name) + || _entries.Any(entry => string.Equals(entry.ServiceName, name, StringComparison.OrdinalIgnoreCase)))]; + } + public Reading> ReadDependents(string serviceName) { // LOCKED SINCE 2026-09-29, when RequiredByPass started asking several entries at once. A diff --git a/tests/Bws.Core.Tests/LaunchCommandComparisonTests.cs b/tests/Bws.Core.Tests/LaunchCommandComparisonTests.cs new file mode 100644 index 00000000..1f409bde --- /dev/null +++ b/tests/Bws.Core.Tests/LaunchCommandComparisonTests.cs @@ -0,0 +1,60 @@ +using Bws.Core.Snapshots; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// Two launch commands compared: the path without case, the arguments exactly - logic report R06, owner's +/// decision of 2026-10-26, which reverses half of backlog 249. +/// +public sealed class LaunchCommandComparisonTests +{ + [Fact] + public void An_argument_whose_case_changed_is_drift() + { + // The report's own scenario: the program reads its arguments, and ACME may not be acme. + var diff = Between(@"""C:\App\svc.exe"" --tenant=ACME", @"""C:\App\svc.exe"" --tenant=acme"); + + Assert.True(diff.Drifted); + Assert.Equal("binaryPath", Assert.Single(Assert.Single(diff.Changed).Differences).Field); + } + + [Theory] + [InlineData(@"""C:\App\svc.exe"" --tenant=ACME", @"""c:\app\SVC.EXE"" --tenant=ACME")] + [InlineData(@"C:\WINDOWS\system32\svchost.exe -k netsvcs -p", @"C:\Windows\System32\svchost.exe -k netsvcs -p")] + [InlineData(@"C:\Program Files\App\svc.exe -x", @"c:\program files\app\svc.exe -x")] + [InlineData(@"C:\Windows\System32\spoolsv.exe", @"c:\windows\system32\SPOOLSV.EXE")] + public void A_path_whose_case_changed_is_not_drift(string was, string now) + { + // The half of backlog 249 that stands: two spellings of one path run one file. + Assert.False(Between(was, now).Drifted); + } + + [Theory] + [InlineData(@"""C:\App\svc.exe"" --tenant=ACME", 16)] + [InlineData(@" ""C:\App\svc.exe""", 18)] + [InlineData(@"""C:\App\svc.exe --never-closed", 30)] + [InlineData(@"C:\WINDOWS\system32\svchost.exe -k netsvcs", 31)] + [InlineData(@"C:\Program Files\App\svc.exe -x", 28)] + [InlineData(@"C:\Program Files\App\svc.exe", 28)] + [InlineData(@"C:\WINDOWS\system32\svchost -k TSLicensing", 27)] + [InlineData(@"\SystemRoot\System32\drivers\x.sys", 34)] + [InlineData("", 0)] + [InlineData(" ", 3)] + public void The_file_ends_where_the_text_alone_says(string command, int end) + { + // Quotes settle it, then the first prefix ending in a name Windows runs, then the first space - the + // last because nothing without the disk can say more, and the price is written beside the rule. + Assert.Equal(end, SnapshotDiff.FileEnds(command)); + } + + private static SnapshotDiff Between(string was, string now) + { + Assert.True(SnapshotDiff.TryBetween(Taken(was), Taken(now), out var diff, out var failure), failure); + + return diff; + } + + private static Snapshot Taken(string command) => + Snapshot.Of([Entries.Any with { BinaryPath = Reading.Present(command) }], note: null, new FakeClock()); +} diff --git a/tests/Bws.Core.Tests/LiveComparisonTests.cs b/tests/Bws.Core.Tests/LiveComparisonTests.cs new file mode 100644 index 00000000..d646df57 --- /dev/null +++ b/tests/Bws.Core.Tests/LiveComparisonTests.cs @@ -0,0 +1,107 @@ +using System.ComponentModel; +using Bws.Core.Snapshots; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// A snapshot compared with this machine asks the manager by name about every entry the reading lacks - +/// logic report S03, owner's decision "detect the hidden ones", 2026-10-26. +/// +/// The listing leaves out, without an error, what this account may not query (Microsoft's page on +/// EnumServicesStatusEx). Until 2026-10-27 such an entry, at equal elevation, came back as removed and +/// counted as drift. +/// +public sealed class LiveComparisonTests +{ + [Fact] + public void An_entry_the_manager_still_holds_is_unlisted_and_not_removed() + { + var machine = new FakeScmCatalog(Entry("Spooler")); + machine.HeldUnlisted.Add("ZTDNS"); + + var diff = Against(Taken(elevated: true, "Spooler", "ZTDNS"), Taken(elevated: true, "Spooler"), machine); + + Assert.Equal("ZTDNS", Assert.Single(diff.Unlisted).ServiceName); + Assert.Empty(diff.Removed); + Assert.Empty(diff.Uncertain); + + // Not drift, so a pipeline is not failed by a change of permissions - and still put in front of a person. + Assert.False(diff.Drifted); + Assert.True(diff.Reported); + } + + [Fact] + public void An_entry_the_manager_does_not_hold_is_removed() + { + var machine = new FakeScmCatalog(Entry("Spooler")); + + var diff = Against(Taken(elevated: true, "Spooler", "Gone"), Taken(elevated: true, "Spooler"), machine); + + Assert.Equal("Gone", Assert.Single(diff.Removed).ServiceName); + Assert.Empty(diff.Unlisted); + Assert.True(diff.Drifted); + Assert.Equal("Gone", Assert.Single(Assert.Single(machine.HeldAsked))); + } + + [Fact] + public void Asked_by_name_a_reading_without_elevation_can_tell_removed_from_unseen() + { + // Two files cannot settle this and call it "cannot tell". The manager can, so both answers come back. + var machine = new FakeScmCatalog(Entry("Spooler")); + machine.HeldUnlisted.Add("Hidden"); + + var diff = Against(Taken(elevated: true, "Spooler", "Hidden", "Gone"), Taken(elevated: false, "Spooler"), machine); + + Assert.Equal("Gone", Assert.Single(diff.Removed).ServiceName); + Assert.Equal("Hidden", Assert.Single(diff.Unlisted).ServiceName); + Assert.Empty(diff.Uncertain); + } + + [Fact] + public void Two_files_are_never_asked_about_and_keep_cannot_tell() + { + Assert.True(SnapshotDiff.TryBetween( + Taken(elevated: true, "Spooler", "Hidden"), Taken(elevated: false, "Spooler"), out var diff, out var failure), failure); + + Assert.Equal("Hidden", Assert.Single(diff.Uncertain).ServiceName); + Assert.Empty(diff.Unlisted); + } + + [Fact] + public void Nothing_missing_asks_the_manager_nothing() + { + var machine = new FakeScmCatalog(Entry("Spooler")); + + Against(Taken(elevated: true, "Spooler"), Taken(elevated: true, "Spooler"), machine); + + Assert.Empty(machine.HeldAsked); + } + + [Fact] + public void A_manager_that_will_not_open_fails_the_whole_comparison() + { + // A whole answer or none - half of one would call the entries it could not ask about removed. + var machine = new FakeScmCatalog(Entry("Spooler")) { FailHeld = new Win32Exception(5) }; + + Assert.Throws(() => + SnapshotDiff.TryAgainstMachine(Taken(elevated: true, "Spooler", "Gone"), Taken(elevated: true, "Spooler"), machine, out _, out _)); + } + + private static SnapshotDiff Against(Snapshot before, Snapshot now, IScmCatalog machine) + { + Assert.True(SnapshotDiff.TryAgainstMachine(before, now, machine, out var diff, out var failure), failure); + + return diff; + } + + private static Snapshot Taken(bool elevated, params string[] names) + { + var taken = Snapshot.Of([.. names.Select(Entry)], note: null, new FakeClock()); + + return taken with { Metadata = taken.Metadata with { Elevated = elevated } }; + } + + private static ScmEntry Entry(string name) => + Entries.Any with { ServiceName = name, DisplayName = $"{name} display name" }; +} diff --git a/tests/Bws.Core.Tests/RequiredByPassParallelTests.cs b/tests/Bws.Core.Tests/RequiredByPassParallelTests.cs index af7e1518..3b094f4c 100644 --- a/tests/Bws.Core.Tests/RequiredByPassParallelTests.cs +++ b/tests/Bws.Core.Tests/RequiredByPassParallelTests.cs @@ -110,6 +110,8 @@ private sealed class Meeting : IScmCatalog, IDisposable public IReadOnlyList ReadStatuses() => []; + public IReadOnlyList ReadHeld(IReadOnlyList names) => []; + public Reading> ReadDependents(string serviceName) { if (Interlocked.Increment(ref _asked) <= 2) diff --git a/tests/Bws.Core.Tests/SnapshotContradictionTests.cs b/tests/Bws.Core.Tests/SnapshotContradictionTests.cs new file mode 100644 index 00000000..e695710a --- /dev/null +++ b/tests/Bws.Core.Tests/SnapshotContradictionTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json.Nodes; +using Bws.Core.Snapshots; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// A document that says two things about one field that cannot both be true is refused when it is read, +/// before its lists decide what a comparison looks at - logic report S06, owner's decision 2026-10-26. +/// +/// Every specimen here starts as a snapshot this build wrote and is damaged in one place, so a refusal can +/// only be about that place. +/// +public sealed class SnapshotContradictionTests +{ + [Fact] + public void An_undamaged_snapshot_is_read() + { + Assert.True(SnapshotJson.TryRead(Damaged(_ => { }), out _, out var failure), failure); + } + + [Fact] + public void A_value_beside_never_asked_about_is_refused_and_named() + { + // The report's own case: startType Automatic and "notRead": ["startType"]. The comparison believed + // the list, so a startup type that changed was called one nobody could compare. + var refused = Refused(entry => entry["notRead"] = new JsonArray("startType")); + + Assert.Contains("'startType'", refused, StringComparison.Ordinal); + Assert.Contains("'Spooler'", refused, StringComparison.Ordinal); + } + + [Fact] + public void A_value_beside_a_refusal_is_refused() + { + var refused = Refused(entry => entry["unreadable"] = new JsonObject + { + ["account"] = new JsonObject { ["errorCode"] = 5, ["message"] = "Access is denied." } + }); + + Assert.Contains("'account'", refused, StringComparison.Ordinal); + } + + [Fact] + public void A_field_both_refused_and_never_asked_about_is_refused() + { + var refused = Refused(entry => + { + entry["triggers"] = null; + entry["notRead"] = new JsonArray("triggers"); + entry["unreadable"] = new JsonObject { ["triggers"] = new JsonObject { ["errorCode"] = 5, ["message"] = "Access is denied." } }; + }); + + Assert.Contains("'triggers' both as refused and as never asked about", refused, StringComparison.Ordinal); + } + + [Fact] + public void A_list_name_spelled_with_a_capital_is_the_same_field() + { + // The comparison reads "StartType" in a list as startType, so the check reads it the same way. + var refused = Refused(entry => entry["notRead"] = new JsonArray("StartType")); + + Assert.Contains("'startType'", refused, StringComparison.Ordinal); + } + + [Fact] + public void A_field_with_no_value_beside_never_asked_about_is_what_this_build_writes() + { + Assert.True( + SnapshotJson.TryRead(Damaged(entry => { entry["startType"] = null; entry["notRead"] = new JsonArray("startType"); }), out _, out var failure), + failure); + } + + [Fact] + public void A_name_nobody_has_heard_of_in_a_list_is_left_alone() + { + // The decision: a later build may write a field this one does not know. + Assert.True( + SnapshotJson.TryRead(Damaged(entry => entry["notRead"] = new JsonArray("somethingNew")), out _, out var failure), + failure); + } + + [Fact] + public void An_empty_service_name_is_refused() + { + var refused = Refused(entry => entry["serviceName"] = string.Empty); + + Assert.Contains("empty service name", refused, StringComparison.Ordinal); + } + + /// The failure TryRead gives the damaged snapshot - and a check that it gave one. + private static string Refused(Action damage) + { + Assert.False(SnapshotJson.TryRead(Damaged(damage), out _, out var failure)); + + return failure!; + } + + /// A snapshot of one ordinary entry, with that entry changed in the file. + private static string Damaged(Action damage) + { + var tree = JsonNode.Parse(SnapshotJson.Render(Snapshot.Of([Entries.Any], note: null, new FakeClock())))!.AsObject(); + + damage(tree["entries"]![0]!.AsObject()); + + return tree.ToJsonString(); + } +} diff --git a/tests/Bws.Core.Tests/SnapshotFileTests.cs b/tests/Bws.Core.Tests/SnapshotFileTests.cs new file mode 100644 index 00000000..67e83f3d --- /dev/null +++ b/tests/Bws.Core.Tests/SnapshotFileTests.cs @@ -0,0 +1,165 @@ +using System.Text; +using Bws.Core.Snapshots; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// A snapshot as a file: written only where it may go and only when this build can read it back, and read +/// only when every byte is text - logic report R11, S01 and S02, 2026-10-27. +/// +/// On the disk, in a directory of their own, for the reason AtomicFileTests gives: what is checked is what +/// the file system does. +/// +public sealed class SnapshotFileTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("bws-snapshot-file-").FullName; + + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private string Target => Path.Combine(_directory, "snapshot.json"); + + [Fact] + public void A_snapshot_too_large_to_read_back_is_not_written() + { + // S02: the reader refuses a file over the ceiling, and until 2026-10-27 the writer wrote one, said + // so, and the same build then called it "not a snapshot this build wrote". + var text = Rendered(); + + var refusal = SnapshotOnDisk.Write(Target, text, force: false, ceiling: Encoding.UTF8.GetByteCount(text) - 1); + + Assert.Equal(DiskFault.WouldBeTooBig, refusal?.Fault); + Assert.False(File.Exists(Target)); + } + + [Fact] + public void A_snapshot_exactly_at_the_ceiling_is_written() + { + // The reader refuses only what is larger, so the writer may not refuse what is equal. + var text = Rendered(); + + Assert.Null(SnapshotOnDisk.Write(Target, text, force: false, ceiling: Encoding.UTF8.GetByteCount(text))); + Assert.True(SnapshotOnDisk.TryLoad(Target, out _, out _)); + } + + [Fact] + public void The_ceiling_counts_the_bytes_written_and_not_the_characters() + { + // Forty characters, eighty bytes in UTF-8 - the length the reader compares. + // A letter outside ASCII, two bytes in UTF-8, written as its number so this file stays ASCII. + var refusal = SnapshotOnDisk.Write(Target, new string((char)0x105, 40), force: false, ceiling: 60); + + Assert.Equal(DiskFault.WouldBeTooBig, refusal?.Fault); + } + + [Fact] + public void Without_force_another_program_s_file_arriving_before_the_move_stays_and_is_named() + { + // R11: the question said nothing was there, and the move used to replace whatever arrived since. + var refusal = SnapshotOnDisk.Write( + Target, Rendered(), force: false, SnapshotOnDisk.Biggest, meanwhile: () => File.WriteAllText(Target, "theirs")); + + Assert.Equal(DiskFault.NotASnapshot, refusal?.Fault); + Assert.Equal("theirs", File.ReadAllText(Target)); + Assert.Single(Directory.GetFileSystemEntries(_directory)); + } + + [Fact] + public void Without_force_a_snapshot_arriving_before_the_move_stays() + { + // Two runs started together by a script, both naming the file from the same second. + var first = Rendered("first"); + + var refusal = SnapshotOnDisk.Write( + Target, Rendered("second"), force: false, SnapshotOnDisk.Biggest, meanwhile: () => File.WriteAllText(Target, first)); + + Assert.Equal(DiskFault.AlreadyThere, refusal?.Fault); + Assert.Equal(first, File.ReadAllText(Target)); + } + + [Fact] + public void Without_force_a_folder_arriving_before_the_move_stays_and_is_named() + { + // The move refuses a folder with the same code as a file (AtomicFileTests), and the person is told it + // is a folder rather than that a snapshot is already there. + var refusal = SnapshotOnDisk.Write( + Target, Rendered(), force: false, SnapshotOnDisk.Biggest, meanwhile: () => Directory.CreateDirectory(Target)); + + Assert.Equal(DiskFault.IsFolder, refusal?.Fault); + Assert.True(Directory.Exists(Target)); + Assert.Single(Directory.GetFileSystemEntries(_directory)); + } + + [Fact] + public void With_force_a_snapshot_arriving_before_the_move_is_replaced() + { + // Told that a snapshot standing there may go - the gap is left as it was, and said in SnapshotOnDisk. + var second = Rendered("second"); + + var refusal = SnapshotOnDisk.Write( + Target, second, force: true, SnapshotOnDisk.Biggest, meanwhile: () => File.WriteAllText(Target, Rendered("first"))); + + Assert.Null(refusal); + Assert.Equal(second, File.ReadAllText(Target)); + } + + [Theory] + [InlineData("utf-8")] + [InlineData("utf-16")] + [InlineData("utf-16BE")] + [InlineData("utf-32")] + [InlineData("utf-32BE")] + public void A_whole_snapshot_behind_any_mark_a_reader_knows_is_read(string encoding) + { + // Saved by something else in another encoding, with its mark - read as it was before S01. + var chosen = Encoding.GetEncoding(encoding); + File.WriteAllBytes(Target, [.. chosen.GetPreamble(), .. chosen.GetBytes(Rendered())]); + + Assert.True(SnapshotOnDisk.TryLoad(Target, out var snapshot, out var refusal), refusal?.Detail); + Assert.Equal("Print Spooler", Assert.Single(snapshot.Entries).DisplayName); + } + + [Fact] + public void A_broken_byte_behind_a_utf8_mark_is_refused_rather_than_replaced() + { + // S01, measured 2026-10-26: the same broken byte was refused without a mark and came back as + // U+FFFD inside the display name with one. + var (before, after) = Split(Rendered()); + File.WriteAllBytes(Target, [0xEF, 0xBB, 0xBF, .. Encoding.UTF8.GetBytes(before), 0xC3, 0x28, .. Encoding.UTF8.GetBytes(after)]); + + Assert.False(SnapshotOnDisk.TryLoad(Target, out _, out var refusal)); + Assert.Equal(DiskFault.NotUtf8, refusal.Fault); + } + + [Fact] + public void A_lone_surrogate_behind_a_utf16_mark_is_refused_rather_than_replaced() + { + var (before, after) = Split(Rendered()); + var little = new UnicodeEncoding(bigEndian: false, byteOrderMark: false); + File.WriteAllBytes(Target, [0xFF, 0xFE, .. little.GetBytes(before), 0x00, 0xD8, .. little.GetBytes(after)]); + + Assert.False(SnapshotOnDisk.TryLoad(Target, out _, out var refusal)); + Assert.Equal(DiskFault.NotUtf8, refusal.Fault); + } + + [Fact] + public void A_path_with_no_length_is_refused_rather_than_read() + { + // S02: the size is asked of the open file, and a device has none. Until 2026-10-27 this path was + // read to its end and called not a snapshot - and a device that never ends would never answer. + Assert.False(SnapshotOnDisk.TryLoad("NUL", out _, out var refusal)); + Assert.Equal(DiskFault.Unreadable, refusal.Fault); + } + + /// A real snapshot of one entry, its note telling two of them apart. + private static string Rendered(string? note = null) => + SnapshotJson.Render(Snapshot.Of([Entries.Any], note, new FakeClock())); + + /// The text cut in the middle of the display name, so a broken byte lands inside a value. + private static (string Before, string After) Split(string text) + { + var at = text.IndexOf("Print Spooler", StringComparison.Ordinal) + "Print".Length; + + return (text[..at], text[at..]); + } +} diff --git a/tests/Bws.Gui.Tests/LiveMachine.cs b/tests/Bws.Gui.Tests/LiveMachine.cs index d8fbd530..d198918c 100644 --- a/tests/Bws.Gui.Tests/LiveMachine.cs +++ b/tests/Bws.Gui.Tests/LiveMachine.cs @@ -159,6 +159,16 @@ public Reading> ReadDependents(string serviceName) : Reading>.Absent(); } + /// + /// Names the manager still holds and leaves out of its listings - an entry whose permissions now keep + /// this account from querying it (logic report S03). Removed from the listing with + /// and named here, it is the shape the enumeration produces without an error. + /// + internal HashSet HeldUnlisted { get; } = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList ReadHeld(IReadOnlyList names) => + [.. names.Where(name => _entries.ContainsKey(name) || HeldUnlisted.Contains(name))]; + private void Throw() { if (FailNext is null) diff --git a/tests/Bws.Gui.Tests/SnapshottingTests.cs b/tests/Bws.Gui.Tests/SnapshottingTests.cs index f4418ea5..d9330103 100644 --- a/tests/Bws.Gui.Tests/SnapshottingTests.cs +++ b/tests/Bws.Gui.Tests/SnapshottingTests.cs @@ -178,6 +178,140 @@ public async Task Putting_the_comparison_away_answers_once_and_gives_the_list_ba Assert.True(model.ListOnScreen); } + [Fact] + public async Task A_comparison_that_comes_back_after_somebody_went_to_another_tab_does_not_take_the_window_back() + { + // Logic report S05: the Tools tab put away a middle that held nothing yet, and the comparison + // arriving afterwards took the window from the page somebody had gone to. + var machine = new LiveMachine(Rows.Entry("Spooler")); + var model = Model(machine); + var path = Path.Combine(_folder, "left-behind.json"); + + await model.Snapshotting.TakeAsync(path); + + var comparing = await Held(machine, () => model.Snapshotting.CompareAsync(path), () => model.Tabs.ShowingTools = true); + + await comparing; + + Assert.Null(model.Snapshotting.Shown); + Assert.True(model.Tabs.ShowingTools); + Assert.Equal(Texts.Of("gui.snapshot.movedOn"), model.Says.Done); + Assert.False(model.Snapshotting.Busy); + } + + [Fact] + public async Task A_comparison_that_comes_back_after_the_form_was_opened_leaves_the_form() + { + // Opening the form puts nothing away - nothing stood in the middle yet - and is a move all the same. + var machine = new LiveMachine(Rows.Entry("Spooler")); + var model = Model(machine); + var path = Path.Combine(_folder, "form-meanwhile.json"); + + await model.Snapshotting.TakeAsync(path); + + await await Held(machine, () => model.Snapshotting.CompareAsync(path), model.Snapshotting.Prepare); + + Assert.Null(model.Snapshotting.Shown); + Assert.NotNull(model.Snapshotting.Form); + } + + [Fact] + public async Task A_comparison_nobody_moved_away_from_still_takes_the_middle() + { + // The other side, so a model that never showed anything would fail here. + var machine = new LiveMachine(Rows.Entry("Spooler")); + var model = Model(machine); + var path = Path.Combine(_folder, "stayed.json"); + + await model.Snapshotting.TakeAsync(path); + + await await Held(machine, () => model.Snapshotting.CompareAsync(path), () => { }); + + Assert.NotNull(model.Snapshotting.Shown); + } + + [Fact] + public async Task A_form_opened_afresh_while_a_snapshot_was_read_is_left_open_when_it_is_written() + { + var machine = new LiveMachine(Rows.Entry("Spooler")); + var model = Model(machine); + var path = Path.Combine(_folder, "second-form.json"); + + model.Snapshotting.Prepare(); + + var taking = await Held(machine, () => model.Snapshotting.TakeAsync(path), () => + { + model.Snapshotting.PutAway(); + model.Snapshotting.Prepare(); + }); + + var reopened = model.Snapshotting.Form; + + await taking; + + Assert.True(File.Exists(path)); + Assert.NotNull(reopened); + Assert.Same(reopened, model.Snapshotting.Form); + } + + [Fact] + public async Task An_entry_the_machine_still_holds_and_no_longer_lists_is_not_a_removal() + { + // Logic report S03, in the window: the manager asked by name says it still holds BITS. + var machine = new LiveMachine(Rows.Entry("Spooler"), Rows.Entry("BITS")); + var model = Model(machine); + var path = Path.Combine(_folder, "hidden-since.json"); + + await model.Snapshotting.TakeAsync(path); + machine.Remove("BITS"); + machine.HeldUnlisted.Add("BITS"); + await model.Snapshotting.CompareAsync(path); + + var line = Assert.Single(model.Snapshotting.Shown!.Lines, line => line.ServiceName == "BITS"); + + Assert.Equal(Texts.Of("gui.diff.row.unlisted"), line.What); + Assert.Equal(Texts.Of("gui.diff.row.notListed"), line.After); + } + + [Fact] + public void A_snapshot_too_large_to_read_back_is_said_on_its_own() + { + Assert.Equal( + Texts.Of("gui.snapshot.fault.wouldBeTooBig", "x.json", 64), + SnapshotWords.NotWritten(new DiskRefusal(DiskFault.WouldBeTooBig, "x.json", string.Empty))); + } + + /// + /// Starts a reading with the machine held, waits until it has reached the machine, does what somebody + /// does in the meantime, and lets the machine go - handing back the reading, still to be awaited. + /// + private static async Task Held(LiveMachine machine, Func reading, Action meanwhile) + { + var reads = machine.FullReads; + + machine.HoldReadings(); + + var started = reading(); + + try + { + for (var waited = 0; machine.FullReads == reads && waited < 10_000; waited += 10) + { + await Task.Delay(10, TestContext.Current.CancellationToken); + } + + Assert.True(machine.FullReads > reads, "the reading never started"); + + meanwhile(); + } + finally + { + machine.ReleaseReadings(); + } + + return started.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + } + private static MainViewModel Model(LiveMachine machine) => new(machine, new SteppedClock(), new Inspector()); /// Every file signed and readable, which is all a snapshot needs from an inspector here. diff --git a/tests/Bws.Integration.Tests/HeldContractTests.cs b/tests/Bws.Integration.Tests/HeldContractTests.cs new file mode 100644 index 00000000..2cbf9342 --- /dev/null +++ b/tests/Bws.Integration.Tests/HeldContractTests.cs @@ -0,0 +1,39 @@ +using Bws.Core; + +namespace Bws.Integration.Tests; + +/// +/// The question a comparison with this machine asks since 2026-10-27 - which names from a snapshot the manager +/// still holds (logic report S03) - put to the real manager. +/// +/// What it cannot reach, said: a name the manager holds and refuses to open. No entry on an ordinary +/// machine refuses an elevated or a standard account its status, so that answer is tested on its value +/// (WindowsEndingFactsReader.Withholds, in UnlistedNeighbourTests) and on a machine through +/// tools/scm-probe/unlisted-probe.ps1. +/// +/// Read-only. Every name is opened for its status alone. +/// +public sealed class HeldContractTests +{ + [Fact] + public void An_entry_that_exists_is_held_and_one_that_does_not_is_not() + { + // RpcSs is on every Windows there is - the manager cannot run without it. + var held = new WindowsScmCatalog().ReadHeld(["RpcSs", "bws-no-such-entry-" + Guid.NewGuid().ToString("N")]); + + Assert.Equal("RpcSs", Assert.Single(held)); + } + + [Fact] + public void A_name_cut_short_by_a_nul_is_not_answered_for_the_name_before_it() + { + // From a file, a name is anything - and handed to Windows it would end at the NUL and be RpcSs. + Assert.Empty(new WindowsScmCatalog().ReadHeld(["RpcSs\0tail"])); + } + + [Fact] + public void Nothing_missing_is_an_empty_answer() + { + Assert.Empty(new WindowsScmCatalog().ReadHeld([])); + } +} diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index 1f025fd4..ab97f242 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -2029,8 +2029,10 @@ $mutations = @( # entry point's own catch cannot turn into a sentence worth reading. Name = 'snapshot: a file too big to be one is refused before it is read' File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' - From = ' if (File.Exists(path) && new FileInfo(path).Length > Biggest)' - To = ' if (File.Exists(path) && new FileInfo(path).Length > long.MaxValue)' + # RE-ANCHORED 2026-10-27 (logic report S02): the open file is asked as well now, and only the + # question about the path answers for a file somebody else holds - which is what the test holds. + From = 'if ((File.Exists(path) && new FileInfo(path).Length > Biggest) || !TryReadText(path, out content))' + To = 'if ((File.Exists(path) && new FileInfo(path).Length > long.MaxValue) || !TryReadText(path, out content))' Test = 'SnapshotSizeGuards.A_file_too_big_to_be_a_snapshot_is_turned_back_before_it_is_even_opened' }, @{ @@ -4910,8 +4912,9 @@ $mutations = @( # with --force, and the mutant takes that away by calling every file not a snapshot. Name = 'snapshot: a readable snapshot is replaced rather than kept' File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' - From = ' return SnapshotJson.TryRead(ReadText(path), out _, out var failure)' - To = ' return SnapshotJson.TryRead(ReadText(path), out _, out var failure) && false' + # RE-ANCHORED 2026-10-27: the text is read through TryReadText since logic report S01. + From = ' return SnapshotJson.TryRead(content, out _, out var failure)' + To = ' return SnapshotJson.TryRead(content, out _, out var failure) && false' Test = 'SnapshotTargetTests.A_snapshot_is_replaced_with_force' }, @{ @@ -4920,8 +4923,10 @@ $mutations = @( # ended after 4045 ms with code 1 and "Insufficient memory". Load had the ceiling, this did not. Name = 'snapshot: a file at the target too big to be one is not read to find out' File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' - From = ' if (new FileInfo(path).Length > Biggest)' - To = ' if (new FileInfo(path).Length > long.MaxValue)' + # RE-ANCHORED 2026-10-27 (logic report S02): asked of the open file, which the target's question + # shares with every read - so the mutant reads sixty five megabytes and calls them not a snapshot. + From = ' if (stream.Length > Biggest)' + To = ' if (stream.Length > long.MaxValue)' Test = 'SnapshotTargetTests.A_file_too_big_to_be_a_snapshot_is_refused_by_its_size_before_it_is_read' }, @@ -4997,9 +5002,11 @@ $mutations = @( # reads. Without this, a repair that simply refused anything unusual would pass above. Name = 'snapshot: a byte order mark is still honoured' File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' - From = 'detectEncodingFromByteOrderMarks: true);' - To = 'detectEncodingFromByteOrderMarks: false);' - Test = 'SnapshotContractTests.A_snapshot_saved_with_a_byte_order_mark_still_reads' + # RE-ANCHORED 2026-10-27 (logic report S01): the mark is read by this build now, not by the + # reader, so the mutant reads a file marked as UTF-16 as UTF-8. + From = '[0xFF, 0xFE, ..] => (new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: true), 2),' + To = '[0xFF, 0xFE, ..] => (new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), 2),' + Test = 'SnapshotFileTests.A_whole_snapshot_behind_any_mark_a_reader_knows_is_read' }, # EIGHT FOR THE KEPT COLUMN LAYOUT, S6d3, 2026-08-13. The first file this product writes for a @@ -5943,7 +5950,8 @@ $mutations = @( # Sends the list fields back through the plain text comparison, which is order for order # and capital for capital. Name = 'diff: a list is compared as a set rather than as its text' - File = 'src\Bws.Core\Snapshots\SnapshotDiff.cs' + # Moved with the value comparison to its own file, 2026-10-27 (logic report R06). + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' From = 'node is JsonArray items && Unordered.Contains(field, StringComparer.Ordinal)' To = 'node is JsonArray items && Unordered.Contains(field, StringComparer.Ordinal) && false' Test = 'SnapshotDiffTests.A_privilege_spelled_differently_is_the_same_privilege' @@ -6200,7 +6208,8 @@ $mutations = @( # Sends the four names Windows itself matches without case back through an exact comparison, # which is what reported LocalSystem against localsystem as drift. Name = 'diff: a name the system matches without case is not a difference' - File = 'src\Bws.Core\Snapshots\SnapshotDiff.cs' + # Moved with the value comparison to its own file, 2026-10-27 (logic report R06). + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' From = 'return TheSystemIgnoresCase.Contains(field, StringComparer.Ordinal)' To = 'return TheSystemIgnoresCase.Contains(field, StringComparer.Ordinal) && false' Test = 'SnapshotDiffTests.A_name_the_system_matches_without_case_is_not_a_difference' @@ -6220,7 +6229,8 @@ $mutations = @( # A list dropped from the set, which is what happens by omission when a field is ADDED and # nobody comes back here. Order-sensitivity returns for that one field and nothing says so. Name = 'diff: every list in the document has been decided about' - File = 'src\Bws.Core\Snapshots\SnapshotDiff.cs' + # Moved with the value comparison to its own file, 2026-10-27 (logic report R06). + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' # RE-ANCHORED 2026-09-06: requiredBy joined the set the day it joined the document, and # the guard below only saw it once it was asked of the TYPE rather than of a rendered # specimen - a list field that is null in the fixture is not an array. @@ -12455,8 +12465,9 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : @{ Name = 'diff json: a field refused to both sides is written with its entries' File = 'src\Bws.Cli\DiffJson.cs' - From = ' [.. diff.RefusedByBoth.Select(field => new RefusedDocument(field.Field, field.Entries))]),' - To = ' [.. diff.RefusedByBoth.Take(0).Select(field => new RefusedDocument(field.Field, field.Entries))]),' + # RE-ANCHORED 2026-10-27: "unlisted" follows it at the end of the document (backlog 623). + From = ' [.. diff.RefusedByBoth.Select(field => new RefusedDocument(field.Field, field.Entries))],' + To = ' [.. diff.RefusedByBoth.Take(0).Select(field => new RefusedDocument(field.Field, field.Entries))],' Test = 'DiffOutputTests.A_field_refused_to_both_snapshots_is_written_last_with_its_entries' } @{ @@ -12513,8 +12524,9 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : @{ Name = 'window snapshot: what the window writes is a snapshot the command line reads' File = 'src\Bws.Gui\ViewModels\Snapshotting.cs' - From = ' AtomicFile.Write(path, text);' - To = ' AtomicFile.Write(path, text + "x");' + # RE-ANCHORED 2026-10-27: the window writes through SnapshotOnDisk.Write since logic report R11. + From = 'SnapshotOnDisk.Write(path, SnapshotJson.Render(read), force: true)' + To = 'SnapshotOnDisk.Write(path, SnapshotJson.Render(read) + "x", force: true)' Test = 'SnapshottingTests.A_snapshot_taken_in_the_window_is_one_the_command_line_reads' } @{ @@ -12528,9 +12540,11 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : @{ # PR #61 review, 2026-10-14: and again right before the write, which is the ask S-8 stands on. Name = 'window snapshot: a file that appears while the machine is read is never written over' - File = 'src\Bws.Gui\ViewModels\Snapshotting.cs' - From = ' if (Refused(path) is { } late)' - To = ' if (Refused(path + ".elsewhere") is { } late)' + # RE-ANCHORED 2026-10-27 (logic report R11): the ask right before the write belongs to the write + # itself now, in the core, for the window and the command line alike. + File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' + From = ' if (WhyNotWritten(target, force) is { } refusal)' + To = ' if (WhyNotWritten(target + ".elsewhere", force) is { } refusal)' Test = 'SnapshottingTests.A_file_that_appears_while_the_machine_is_read_is_still_never_written_over' } @{ @@ -12791,9 +12805,12 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : @{ Name = 'window snapshot form: the form goes once the snapshot is written' File = 'src\Bws.Gui\ViewModels\Snapshotting.cs' - From = ' // with the note still in it, so another folder is one more press rather than a retyping. - Form = null;' - To = ' // with the note still in it, so another folder is one more press rather than a retyping.' + # RE-ANCHORED 2026-10-27: only a form nobody opened afresh meanwhile goes (logic report S05). + From = ' if (turn == _turn) + { + Form = null;' + To = ' if (turn == _turn) + {' Test = 'SnapshotFormTests.The_note_typed_in_the_form_is_kept_inside_the_snapshot_and_the_form_goes' } @{ @@ -14326,6 +14343,255 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = 'new SystemClock()));' Test = 'PlanOnlyGuards.Every_runner_on_the_real_machine_reads_the_process_again_before_ending_it' } + # ---- Logic report, package L2 (snapshots and comparison), 2026-10-27 - docs/ANALIZA-RAPORTU-LOGIKI-20261026.md ---- + @{ + # R11: the move itself refuses to replace, so a file arriving after the question stays. + Name = 'atomic file: a new file never replaces what got there first' + File = 'src\Bws.Core\Snapshots\AtomicFile.cs' + From = 'File.Move(beside, full, overwrite: replace);' + To = 'File.Move(beside, full, overwrite: true);' + Test = 'AtomicFileTests.A_new_file_leaves_whatever_got_there_first_and_nothing_beside_it' + } + @{ + # R11: "already there" is the answer for a file standing there - the number Windows gives for it. + Name = 'atomic file: a file standing there is a refusal and not a failure' + File = 'src\Bws.Core\Snapshots\AtomicFile.cs' + From = 'if (refused.HResult is AlreadyExists or FileExists)' + To = 'if (refused.HResult is FileExists)' + Test = 'AtomicFileTests.A_new_file_leaves_whatever_got_there_first_and_nothing_beside_it' + } + @{ + # R11: without --force the snapshot goes through the move that may not replace. + Name = 'snapshot write: without force nothing that arrived meanwhile is replaced' + File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' + From = ' if (force) + { + AtomicFile.Write(target, text);' + To = ' if (force || text.Length >= 0) + { + AtomicFile.Write(target, text);' + Test = 'SnapshotFileTests.Without_force_a_snapshot_arriving_before_the_move_stays' + } + @{ + # R11: what arrived is named by asking again - another program's file is not "a snapshot". + Name = 'snapshot write: what arrived meanwhile is named for what it is' + File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' + From = ': WhyNotWritten(target, force: false) ?? new DiskRefusal(DiskFault.AlreadyThere, target, string.Empty);' + To = ': new DiskRefusal(DiskFault.AlreadyThere, target, string.Empty);' + Test = 'SnapshotFileTests.Without_force_another_program_s_file_arriving_before_the_move_stays_and_is_named' + } + @{ + # R11: a folder somebody typed that is not there stays the usage sentence after the write moved. + Name = 'snapshot write: a missing folder is the sentence about the typed path' + File = 'src\Bws.Cli\SnapshotFiles.cs' + From = 'when (theirs is DirectoryNotFoundException or UnauthorizedAccessException)' + To = 'when (theirs is UnauthorizedAccessException)' + Test = 'SnapshotTargetTests.A_folder_that_is_not_there_is_the_sentence_about_the_path_somebody_typed' + } + @{ + # S02: the ceiling the reader keeps counts bytes, and so must the writer. + Name = 'snapshot write: the ceiling counts bytes and not characters' + File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' + From = 'if (Encoding.UTF8.GetByteCount(text) > ceiling)' + To = 'if (text.Length > ceiling)' + Test = 'SnapshotFileTests.The_ceiling_counts_the_bytes_written_and_not_the_characters' + } + @{ + # S02: the reader refuses only what is larger, so the writer may not refuse what is equal. + Name = 'snapshot write: a snapshot at the ceiling is written' + File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' + From = 'if (Encoding.UTF8.GetByteCount(text) > ceiling)' + To = 'if (Encoding.UTF8.GetByteCount(text) >= ceiling)' + Test = 'SnapshotFileTests.A_snapshot_exactly_at_the_ceiling_is_written' + } + @{ + # S02: a device has no length, and is refused rather than asked for one. + Name = 'snapshot read: a path with no length is refused' + File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' + From = 'if (!stream.CanSeek)' + To = 'if (!stream.CanSeek && stream.CanWrite)' + Test = 'SnapshotFileTests.A_path_with_no_length_is_refused_rather_than_read' + } + @{ + # S01: behind a UTF-8 mark the decoder still refuses a broken byte. + Name = 'snapshot read: a broken byte behind a utf8 mark is refused' + File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' + From = '[0xEF, 0xBB, 0xBF, ..] => (new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), 3),' + To = '[0xEF, 0xBB, 0xBF, ..] => (new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false), 3),' + Test = 'SnapshotFileTests.A_broken_byte_behind_a_utf8_mark_is_refused_rather_than_replaced' + } + @{ + # S01: behind a UTF-16 mark too. + Name = 'snapshot read: a lone surrogate behind a utf16 mark is refused' + File = 'src\Bws.Core\Snapshots\SnapshotOnDisk.cs' + From = '[0xFF, 0xFE, ..] => (new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: true), 2),' + To = '[0xFF, 0xFE, ..] => (new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: false), 2),' + Test = 'SnapshotFileTests.A_lone_surrogate_behind_a_utf16_mark_is_refused_rather_than_replaced' + } + @{ + # S06: an empty name describes no entry. + Name = 'snapshot read: an empty service name is refused' + File = 'src\Bws.Core\Snapshots\Snapshot.cs' + From = 'if (entry.ServiceName.Length == 0)' + To = 'if (entry.ServiceName.Length < 0)' + Test = 'SnapshotContradictionTests.An_empty_service_name_is_refused' + } + @{ + # S06: a field both refused and never asked about. + Name = 'snapshot read: a field in both lists is refused' + File = 'src\Bws.Core\Snapshots\Snapshot.cs' + From = 'if (notRead.FirstOrDefault(refused.Contains) is { } both)' + To = 'if (notRead.FirstOrDefault(name => refused.Contains(name + "?")) is { } both)' + Test = 'SnapshotContradictionTests.A_field_both_refused_and_never_asked_about_is_refused' + } + @{ + # S06: a value beside "never asked about". + Name = 'snapshot read: a value beside never asked about is refused' + File = 'src\Bws.Core\Snapshots\Snapshot.cs' + From = 'return notRead.Concat(refused).FirstOrDefault(field => document[field] is not null) is { } held' + To = 'return refused.FirstOrDefault(field => document[field] is not null) is { } held' + Test = 'SnapshotContradictionTests.A_value_beside_never_asked_about_is_refused_and_named' + } + @{ + # S06: a value beside a refusal. + Name = 'snapshot read: a value beside a refusal is refused' + File = 'src\Bws.Core\Snapshots\Snapshot.cs' + From = 'return notRead.Concat(refused).FirstOrDefault(field => document[field] is not null) is { } held' + To = 'return notRead.FirstOrDefault(field => document[field] is not null) is { } held' + Test = 'SnapshotContradictionTests.A_value_beside_a_refusal_is_refused' + } + @{ + # S06: a list name is read the way the comparison reads it. + Name = 'snapshot read: a list name with a capital is the same field' + File = 'src\Bws.Core\Snapshots\Snapshot.cs' + From = 'var notRead = (entry.NotRead ?? []).Select(EntryDocument.Camel).ToList();' + To = 'var notRead = (entry.NotRead ?? []).ToList();' + Test = 'SnapshotContradictionTests.A_list_name_spelled_with_a_capital_is_the_same_field' + } + @{ + # R06: the launch command has its own form, and is no longer one of the names without case. + Name = 'diff: a launch command is compared in its own form' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' + From = 'if (field == Command)' + To = 'if (field == Identity)' + Test = 'LaunchCommandComparisonTests.A_path_whose_case_changed_is_not_drift' + } + @{ + # R06: the arguments keep their case. + Name = 'diff: the arguments of a launch command keep their case' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' + From = 'return command[..end].ToLowerInvariant() + command[end..];' + To = 'return command.ToLowerInvariant();' + Test = 'LaunchCommandComparisonTests.An_argument_whose_case_changed_is_drift' + } + @{ + # R06: with no prefix ending in a name Windows runs, the file ends at the first space. + Name = 'diff: a command with no extension ends its file at the first space' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' + From = 'firstSpace ??= space;' + To = 'firstSpace ??= command.Length;' + Test = 'LaunchCommandComparisonTests.The_file_ends_where_the_text_alone_says' + } + @{ + # S03: an entry the manager still holds is unlisted, not removed. + Name = 'live diff: an entry the manager holds is not removed' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Unseen.cs' + From = 'var into = stillHeld.Contains(entry.ServiceName) ? unlisted' + To = 'var into = stillHeld.Contains(entry.DisplayName) ? unlisted' + Test = 'LiveComparisonTests.An_entry_the_manager_still_holds_is_unlisted_and_not_removed' + } + @{ + # S03: once the manager answered, elevation no longer makes a missing entry "cannot tell". + Name = 'live diff: the manager settles what elevation could not' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Unseen.cs' + From = ': held is null && mayBeUnseen ? uncertain' + To = ': mayBeUnseen ? uncertain' + Test = 'LiveComparisonTests.Asked_by_name_a_reading_without_elevation_can_tell_removed_from_unseen' + } + @{ + # S03: a comparison with this machine asks it. + Name = 'live diff: the machine is asked' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.cs' + From = 'return TryBetween(before, now, machine.ReadHeld, out diff, out failure);' + To = 'return TryBetween(before, now, held: null, out diff, out failure);' + Test = 'LiveComparisonTests.An_entry_the_manager_still_holds_is_unlisted_and_not_removed' + } + @{ + # S03: an entry still held is put in front of a person. + Name = 'live diff: an entry still held is reported' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.cs' + From = '|| Unlisted.Count > 0;' + To = '|| Unlisted.Count < 0;' + Test = 'LiveComparisonTests.An_entry_the_manager_still_holds_is_unlisted_and_not_removed' + } + @{ + # S03: a name cut short by a NUL is not answered for the name before it. + Name = 'live diff: a name holding a NUL is not asked' + File = 'src\Bws.Core\WindowsScmCatalog.Held.cs' + From = ' if (name.Contains(' + To = ' if (name.Length > 256 && name.Contains(' + Test = 'HeldContractTests.A_name_cut_short_by_a_nul_is_not_answered_for_the_name_before_it' + } + @{ + # S03: the window compares with the machine by asking it. + Name = 'window live diff: the machine is asked' + File = 'src\Bws.Gui\ViewModels\Snapshotting.cs' + From = 'SnapshotDiff.TryAgainstMachine(earlier, Read(inspector, note: null), _catalog, out var diff, out var failure)' + To = 'SnapshotDiff.TryBetween(earlier, Read(inspector, note: null), out var diff, out var failure)' + Test = 'SnapshottingTests.An_entry_the_machine_still_holds_and_no_longer_lists_is_not_a_removal' + } + @{ + # S03 and backlog 623: a script finds them under their own key. + Name = 'live diff json: entries still held have their own key' + File = 'src\Bws.Cli\DiffJson.cs' + From = ' [.. diff.Unlisted.Select(Presence)]),' + To = ' [.. diff.Uncertain.Select(Presence)]),' + Test = 'DiffOutputTests.A_script_finds_entries_the_manager_still_holds_under_their_own_key_at_the_end' + } + @{ + # S03: and the summary counts them as seen by one side. + Name = 'live diff text: entries still held are counted as seen by one side' + File = 'src\Bws.Cli\DiffText.cs' + From = 'var seenByOne = diff.Uncertain.Count + diff.Unlisted.Count;' + To = 'var seenByOne = diff.Uncertain.Count;' + Test = 'DiffOutputTests.An_entry_the_manager_still_holds_has_its_own_heading_and_is_seen_by_one_side' + } + @{ + # S05: going to another tab is a move, though nothing stood in the middle yet. + Name = 'window comparison: going elsewhere while it reads is a move' + File = 'src\Bws.Gui\ViewModels\Snapshotting.cs' + From = ' internal bool PutAway() + { + _turn++;' + To = ' internal bool PutAway() + {' + Test = 'SnapshottingTests.A_comparison_that_comes_back_after_somebody_went_to_another_tab_does_not_take_the_window_back' + } + @{ + # S05: so is opening the form. + Name = 'window comparison: opening the form while it reads is a move' + File = 'src\Bws.Gui\ViewModels\Snapshotting.cs' + From = ' _turn++; + Shown = null;' + To = ' Shown = null;' + Test = 'SnapshottingTests.A_comparison_that_comes_back_after_the_form_was_opened_leaves_the_form' + } + @{ + # S05: a comparison that comes back after a move does not take the middle. + Name = 'window comparison: a comparison after a move does not take the middle' + File = 'src\Bws.Gui\ViewModels\Snapshotting.Turns.cs' + From = 'if (turn == _turn)' + To = 'if (turn <= _turn)' + Test = 'SnapshottingTests.A_comparison_that_comes_back_after_somebody_went_to_another_tab_does_not_take_the_window_back' + } + @{ + # S05: a form opened afresh while a snapshot was read is left open. + Name = 'window snapshot form: a form opened afresh meanwhile stays' + File = 'src\Bws.Gui\ViewModels\Snapshotting.cs' + From = 'if (turn == _turn)' + To = 'if (turn <= _turn)' + Test = 'SnapshottingTests.A_form_opened_afresh_while_a_snapshot_was_read_is_left_open_when_it_is_written' + } ) $mutations