diff --git a/docs/CLI.md b/docs/CLI.md index 649a4e9..92a6c75 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -382,6 +382,8 @@ failures, and path-sanitization notes — the same log the desktop app shows under **View > Activity log**. Tokens are never written to the log, or anywhere else on disk; see [SECURITY.md](../SECURITY.md). +Each entry is one line that starts with its timestamp. Text that came from a repository, such as a file path, is written with control characters shown as `\uXXXX`, and the lines of an exception are indented under their entry, so nothing in a repository can add or imitate an entry. Everything the CLI prints as text shows such characters the same way, and on a console whose code page cannot represent a character (code page 437 or 850, for example) that character is shown in the same form too; the JSON modes use JSON's own escaping. + ## Exit codes | Code | Meaning | What to do | diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index b45f715..29a908a 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -96,6 +96,7 @@ flowchart LR | T11 | Release pipeline | Tampering | Compromised action or cache poisons a release | Low | Critical | Build runs in the standards' shared release workflow (SLSA Build L3 provenance, `--signer-repo KofTwentyTwo/standards`), SHA-pinned actions, no caches, read-only tokens, publishing tokens only in the tag-restricted `release` environment, `protect-release-tags`, required gates re-run on the tag | Mitigated | | T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated | | T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated | +| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) | | T13 | CLI / data directory | Tampering | A malicious `accounts.json` or `settings.json` redirects targets | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained | Accepted (same-user boundary) | ## 7. Accepted risks @@ -118,3 +119,4 @@ flowchart LR | 2026-10-10 | v1.1.0 | James Maes (@KofTwentyTwo) | T5: accounts can reference the default token (`TokenSource = Default`) instead of holding a copy; one `AccountTokenResolver` serves the app and the CLI, so rotating the default token in Settings rotates every such account (#102, docs/plans/default-token-ux.md) | | 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T14 added, T4/T8/T9 updated: an existing folder is fetched only when its origin names the listed repository, through the listed URL, and the folder's own `url.*.insteadOf` and (for a non-HTTPS fetch) `core.sshCommand` are refused; the git credential callback and the WSL credential helper answer only for the listed host over HTTPS; WSL organization and repository names validated as single names (#139) | | 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T3/T4 updated: path recovery writes and deletes only through `WorkingTree` (inside the working directory, never in `.git`, never through a link); `.git` and its Windows aliases are invalid path segments; the stored recovery file's paths are validated on load; cleanup of a failed clone does not follow links (#141) | +| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T15 added: text from repositories and servers is shown with control, separator and format characters made visible, in the activity log and in everything the CLI prints as text, including characters a legacy console code page would turn into control bytes; the desktop app's surfaces are named as not covered (#143) | diff --git a/gclo.Cli.Tests/OrgsCommandTests.cs b/gclo.Cli.Tests/OrgsCommandTests.cs index f16b2b6..536db20 100644 --- a/gclo.Cli.Tests/OrgsCommandTests.cs +++ b/gclo.Cli.Tests/OrgsCommandTests.cs @@ -65,6 +65,21 @@ public sealed class OrgsCommandTests + [Fact] + public async Task Plain_ALoginFromTheServer_ReachesTheTerminalInVisibleForm() + { + using var console = new ConsoleCapture(); + using IDisposable _ = Token("ghp_x"); + var lister = new FakeOrgLister { Result = ["ac" + (char)0x1B + "[2Jme"] }; + + int code = await Run(lister, "--token-env", EnvVar); + + Assert.Equal(0, code); + Assert.Equal("ac" + "\\" + "u001B[2Jme", console.Out.TrimEnd()); + } + + + [Fact] public async Task Json_PrintsAnArray() { diff --git a/gclo.Cli.Tests/ProgressPrinterTests.cs b/gclo.Cli.Tests/ProgressPrinterTests.cs index d95aa2f..6ecea4e 100644 --- a/gclo.Cli.Tests/ProgressPrinterTests.cs +++ b/gclo.Cli.Tests/ProgressPrinterTests.cs @@ -39,6 +39,38 @@ public sealed class ProgressPrinterTests + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TextModes_ShowControlCharactersFromRepositoryContentAsVisibleText(bool quiet) + { + using var console = new ConsoleCapture(); + var printer = new ProgressPrinter(quiet ? OutputMode.Quiet : OutputMode.Text); + string escape = ((char)0x1B).ToString(); + // An error that quotes a path from a repository's tree: a newline that would + // start a fake line, and an escape sequence the terminal would obey. + string error = "invalid path 'a\nother Done" + escape + "[2J'"; + + printer.Report(new RepoProgress("we" + escape + "[31mird", SyncStatus.Cloning)); + printer.Report(new RepoProgress("repo", SyncStatus.Failed, error)); + + string line = Assert.Single(Lines(console.Error)); + Assert.StartsWith("repo Failed invalid path 'a", line, StringComparison.Ordinal); + Assert.Contains(Visible(0x0A) + "other Done", line); + Assert.Contains(Visible(0x1B) + "[2J'", line); + // Compared as characters: a culture-aware text search treats the escape character as ignorable. + Assert.DoesNotContain((char)0x1B, console.Error); + Assert.DoesNotContain((char)0x1B, console.Out); + if(!quiet) + { + Assert.Equal("we" + Visible(0x1B) + "[31mird Cloning", Assert.Single(Lines(console.Out))); + } + // The record for the summary document keeps the text as it was: JSON has its own escaping. + Assert.Equal(error, Assert.Single(printer.Failures).Error); + } + + + [Fact] public void Quiet_PrintsOnlyFailures() { @@ -116,6 +148,12 @@ public sealed class ProgressPrinterTests + /// The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits. + private static string Visible(int unit) + => "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture); + + + private static string[] Lines(string text) => text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray(); } diff --git a/gclo.Cli.Tests/ReposCommandTests.cs b/gclo.Cli.Tests/ReposCommandTests.cs index 5a5f2e2..f7efd12 100644 --- a/gclo.Cli.Tests/ReposCommandTests.cs +++ b/gclo.Cli.Tests/ReposCommandTests.cs @@ -83,6 +83,29 @@ public sealed class ReposCommandTests + [Fact] + public async Task Plain_NamesAndBranchesFromTheServer_ReachTheTerminalInVisibleForm() + { + using var console = new ConsoleCapture(); + using IDisposable _ = Token("ghp_x"); + string escape = ((char)0x1B).ToString(); + var lister = new FakeRepoLister { Result = [Repo("api" + escape + "[2J", "ma" + (char)0x0A + "in")] }; + + int code = await Run(lister, "--org", "acme", "--token-env", EnvVar); + + Assert.Equal(0, code); + Assert.DoesNotContain((char)0x1B, console.Out); + Assert.Equal("api" + Visible(0x1B) + "[2J ma" + Visible(0x0A) + "in", console.Out.TrimEnd()); + } + + + + /// The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits. + private static string Visible(int unit) + => "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture); + + + [Fact] public async Task Filters_NarrowTheListing_AndAnEmptyResultSaysWhy() { diff --git a/gclo.Cli.Tests/TerminalTests.cs b/gclo.Cli.Tests/TerminalTests.cs new file mode 100644 index 0000000..d9858ab --- /dev/null +++ b/gclo.Cli.Tests/TerminalTests.cs @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text; +using System.Text.RegularExpressions; +using gclo.Engine; + + +namespace gclo.Cli.Tests; + + +/// +/// is the one place the CLI writes to the console, and what it +/// writes is safe for the terminal in the console's own encoding (#143). +/// +public sealed partial class TerminalTests +{ + private const char Escape = (char)0x1B; + + static TerminalTests() + { + // Legacy code pages are in the framework but not registered by default. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + + + // ---------------------------------------------------------------- Unicode consoles + + [Theory] + [InlineData(65001)] // UTF-8 + [InlineData(1200)] // UTF-16 + [InlineData(12000)] // UTF-32 + public void Safe_OnAUnicodeConsole_IsTheVisibleFormAndNothingMore(int codePage) + { + var encoding = Encoding.GetEncoding(codePage); + string text = "caf" + (char)0xE9 + " " + (char)0x65E5 + (char)0x672C + " " + (char)0x2190 + " plain"; + + Assert.Same(text, Terminal.Safe(text, encoding)); + Assert.Equal(DisplayText.Escape("a\nb" + Escape + "[2J"), Terminal.Safe("a\nb" + Escape + "[2J", encoding)); + Assert.Equal("", Terminal.Safe(null, encoding)); + } + + + + [Fact] + public void Safe_NullEncoding_IsACallerError() + { + Assert.Throws(() => Terminal.Safe("text", null!)); + } + + + + // ---------------------------------------------------------------- legacy code pages + + [Theory] + [InlineData(437)] + [InlineData(850)] + public void Safe_OnALegacyCodePage_ACharacterItWouldTurnIntoAControlByte_IsShownVisibly(int codePage) + { + var encoding = Encoding.GetEncoding(codePage); + // Printable characters that are not control characters, so the first pass leaves + // them alone; code page 437 "best-fits" the arrow to ESC and the inverse circle + // to a line feed. + string hostile = "x:" + (char)0x2190 + "[2J" + (char)0x25D9 + "other Done"; + Assert.Same(hostile, DisplayText.Escape(hostile)); + + string safe = Terminal.Safe(hostile, encoding); + + Assert.Equal("x:" + Visible(0x2190) + "[2J" + Visible(0x25D9) + "other Done", safe); + AssertNoControlBytes(encoding.GetBytes(safe)); + } + + + + [Theory] + [InlineData(437)] + [InlineData(850)] + [InlineData(1252)] + [InlineData(932)] + public void Safe_OnALegacyCodePage_NoCharacterOfTheBasicPlane_ReachesTheTerminalAsAControlByte(int codePage) + { + var encoding = Encoding.GetEncoding(codePage); + var failures = new List(); + for(int code = 0; code <= 0xFFFF; code++) + { + if(code is >= 0xD800 and <= 0xDFFF) + { + continue; // a lone surrogate is covered below + } + + byte[] bytes = encoding.GetBytes(Terminal.Safe("a" + (char)code + "b", encoding)); + if(bytes.Any(b => b < 0x20 || b == 0x7F)) + { + failures.Add(code.ToString("X4", System.Globalization.CultureInfo.InvariantCulture)); + } + } + + Assert.True(failures.Count == 0, "control bytes for: " + string.Join(' ', failures.Take(40))); + } + + + + [Theory] + [InlineData(437)] + [InlineData(1252)] + public void Safe_OnALegacyCodePage_KeepsWhatTheCodePageCanShow_AndEscapesWhatItCannot(int codePage) + { + var encoding = Encoding.GetEncoding(codePage); + string emoji = char.ConvertFromUtf32(0x1F600); + + // e-acute is in both code pages; the kanji and the emoji are in neither. + string safe = Terminal.Safe("caf" + (char)0xE9 + " " + (char)0x65E5 + " " + emoji + " end", encoding); + + Assert.Equal("caf" + (char)0xE9 + " " + Visible(0x65E5) + " " + Visible(emoji[0]) + Visible(emoji[1]) + " end", safe); + } + + + + [Fact] + public void Safe_IsStable_WhenAppliedTwice() + { + var encoding = Encoding.GetEncoding(437); + string once = Terminal.Safe("a\nb" + (char)0x2190 + (char)0x65E5, encoding); + + Assert.Equal(once, Terminal.Safe(once, encoding)); + } + + + + // ---------------------------------------------------------------- the writers + + [Fact] + public void OutAndError_WriteOneSafeLine_ToTheirOwnStream() + { + using var console = new ConsoleCapture(); + + Terminal.Out("repo" + Escape + "[31m Done\nforged Done"); + Terminal.Error("repo Failed bad" + Escape + "[2J\nforged"); + + Assert.Equal("repo" + Visible(0x1B) + "[31m Done" + Visible(0x0A) + "forged Done" + Environment.NewLine, console.Out); + Assert.Equal("repo Failed bad" + Visible(0x1B) + "[2J" + Visible(0x0A) + "forged" + Environment.NewLine, console.Error); + } + + + + [Fact] + public void OutVerbatim_IsForFixedTextAndJson_AndChangesNothing() + { + using var console = new ConsoleCapture(); + + Terminal.OutVerbatim("Usage:\n gclo sync"); + + Assert.Equal("Usage:\n gclo sync" + Environment.NewLine, console.Out); + Assert.Equal("", console.Error); + } + + + + [Fact] + public void ErrorPrompt_StaysOnItsLine_UntilTheLineBreak() + { + using var console = new ConsoleCapture(); + + Terminal.ErrorPrompt("Token: "); + Assert.Equal("Token: ", console.Error); + + Terminal.ErrorLineBreak(); + Assert.Equal("Token: " + Environment.NewLine, console.Error); + Assert.Equal("", console.Out); + } + + + + // ---------------------------------------------------------------- nothing bypasses it + + [Fact] + public void NoOtherFileOfTheCli_WritesToTheConsoleDirectly() + { + // Every line the CLI prints goes through Terminal, so a new message cannot + // forget to be made safe. The check is on the source: an output that bypasses + // Terminal would otherwise pass every behavioural test that does not happen + // to feed it hostile text. + string cli = Path.Combine(RepoRoot, "gclo.Cli"); + var offenders = new List(); + foreach(string file in Directory.EnumerateFiles(cli, "*.cs", SearchOption.TopDirectoryOnly)) + { + if(string.Equals(Path.GetFileName(file), "Terminal.cs", StringComparison.Ordinal)) + { + continue; + } + + string[] lines = File.ReadAllLines(file); + for(int i = 0; i < lines.Length; i++) + { + if(DirectConsoleWrite().IsMatch(lines[i])) + { + offenders.Add($"{Path.GetFileName(file)}:{i + 1}: {lines[i].Trim()}"); + } + } + } + + Assert.True(offenders.Count == 0, "write through Terminal instead:" + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + + + // ---------------------------------------------------------------- helpers + + private static string RepoRoot + { + get + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + while(current is not null && !File.Exists(Path.Combine(current.FullName, "gclo.slnx"))) + { + current = current.Parent; + } + return current?.FullName ?? throw new FileNotFoundException("gclo.slnx not found above the test directory"); + } + } + + + + private static void AssertNoControlBytes(byte[] bytes) + => Assert.DoesNotContain(bytes, b => b < 0x20 || b == 0x7F); + + + + /// The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits. + private static string Visible(int unit) + => "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture); + + + + [GeneratedRegex(@"\bConsole\s*\.\s*(Out|Error)\b|\bConsole\s*\.\s*Write(Line)?\s*\(", RegexOptions.ExplicitCapture, 1000)] + private static partial Regex DirectConsoleWrite(); +} diff --git a/gclo.Cli/AccountsCommand.cs b/gclo.Cli/AccountsCommand.cs index 4cdfad6..a7de39e 100644 --- a/gclo.Cli/AccountsCommand.cs +++ b/gclo.Cli/AccountsCommand.cs @@ -122,7 +122,7 @@ internal static class AccountsCommand switch(reader.Current) { case "--help" or "-h": - Console.Out.WriteLine(HelpText); + Terminal.OutVerbatim(HelpText); return ExitCodes.Success; case "--json": reader.RejectValue(); @@ -148,7 +148,7 @@ internal static class AccountsCommand a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot, a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a))) .ToList(); - Console.Out.WriteLine(JsonSerializer.Serialize( + Terminal.OutVerbatim(JsonSerializer.Serialize( summaries, CliJsonContext.Default.IReadOnlyListAccountSummary)); return ExitCodes.Success; } @@ -156,7 +156,7 @@ internal static class AccountsCommand if(accounts.Count == 0) { // Diagnostics go to stderr so redirected stdout stays clean (and empty). - Console.Error.WriteLine("No accounts yet. Create one with 'gclo accounts add' or in the gclo desktop app."); + Terminal.Error("No accounts yet. Create one with 'gclo accounts add' or in the gclo desktop app."); return ExitCodes.Success; } @@ -165,7 +165,7 @@ internal static class AccountsCommand int targetWidth = accounts.Max(a => a.TargetRoot.Length); foreach(Account account in accounts) { - Console.Out.WriteLine( + Terminal.Out( $"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} " + $"{account.TargetRoot.PadRight(targetWidth)} {TokenSourceLabel(account).PadRight(7)} {FormatLastSync(account.LastSyncUtc)}"); } @@ -204,7 +204,7 @@ internal static class AccountsCommand switch(reader.Current) { case "--help" or "-h": - Console.Out.WriteLine(HelpText); + Terminal.OutVerbatim(HelpText); return ExitCodes.Success; case "--name": name = reader.RequireValue(); @@ -263,7 +263,7 @@ internal static class AccountsCommand TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own, }; SaveOrFail(store, account, token); - Console.Out.WriteLine($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : ".")); + Terminal.Out($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : ".")); return ExitCodes.Success; } catch(Exception ex) @@ -298,7 +298,7 @@ internal static class AccountsCommand switch(reader.Current) { case "--help" or "-h": - Console.Out.WriteLine(HelpText); + Terminal.OutVerbatim(HelpText); return ExitCodes.Success; case "--name": name = reader.RequireValue(); @@ -370,7 +370,7 @@ internal static class AccountsCommand string tokenNote = tokenOptions.UseDefault ? " (now uses the default token)." : token is null ? "." : " (token replaced)."; - Console.Out.WriteLine($"Account '{updated.Name}' updated" + tokenNote); + Terminal.Out($"Account '{updated.Name}' updated" + tokenNote); return ExitCodes.Success; } catch(Exception ex) @@ -394,7 +394,7 @@ internal static class AccountsCommand switch(reader.Current) { case "--help" or "-h": - Console.Out.WriteLine(HelpText); + Terminal.OutVerbatim(HelpText); return ExitCodes.Success; case "--name": name = reader.RequireValue(); @@ -415,7 +415,7 @@ internal static class AccountsCommand (AccountsStore store, _) = open(log); Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store); store.Delete(existing.Id); // the store logs the deletion - Console.Out.WriteLine($"Account '{existing.Name}' removed."); + Terminal.Out($"Account '{existing.Name}' removed."); return ExitCodes.Success; } catch(Exception ex) diff --git a/gclo.Cli/OrgsCommand.cs b/gclo.Cli/OrgsCommand.cs index 087be62..f47e955 100644 --- a/gclo.Cli/OrgsCommand.cs +++ b/gclo.Cli/OrgsCommand.cs @@ -68,7 +68,7 @@ internal static class OrgsCommand switch(reader.Current) { case "--help" or "-h": - Console.Out.WriteLine(HelpText); + Terminal.OutVerbatim(HelpText); return ExitCodes.Success; case "--json": reader.RejectValue(); @@ -103,13 +103,13 @@ internal static class OrgsCommand if(json) { - Console.Out.WriteLine(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString)); + Terminal.OutVerbatim(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString)); } else { foreach(string login in logins) { - Console.Out.WriteLine(login); + Terminal.Out(login); } } return ExitCodes.Success; diff --git a/gclo.Cli/Program.cs b/gclo.Cli/Program.cs index 4248207..a12dc6c 100644 --- a/gclo.Cli/Program.cs +++ b/gclo.Cli/Program.cs @@ -77,7 +77,7 @@ internal static class Program // repositories are marked Canceled, and the summary still prints. e.Cancel = true; cts.Cancel(); - Console.Error.WriteLine("Canceling... (press Ctrl+C again to abort immediately)"); + Terminal.Error("Canceling... (press Ctrl+C again to abort immediately)"); } // Second Ctrl+C: e.Cancel stays false and the process terminates. }; @@ -88,18 +88,18 @@ internal static class Program } catch(CliUsageException ex) { - Console.Error.WriteLine(ex.Message); - Console.Error.WriteLine("Run 'gclo --help' for usage."); + Terminal.Error(ex.Message); + Terminal.Error("Run 'gclo --help' for usage."); return ExitCodes.Fatal; } catch(CliErrorException ex) { - Console.Error.WriteLine(ex.Message); + Terminal.Error(ex.Message); return ex.ExitCode; } catch(OperationCanceledException) when(cts.IsCancellationRequested) { - Console.Error.WriteLine("Canceled."); + Terminal.Error("Canceled."); return ExitCodes.Partial; } catch(Exception ex) @@ -129,12 +129,12 @@ internal static class Program // The log must never make a bad situation worse. } - Console.Error.WriteLine($"Unexpected error ({ex.GetType().Name}): {ex.Message}"); + Terminal.Error($"Unexpected error ({ex.GetType().Name}): {ex.Message}"); if(details.Length > 0) { - Console.Error.WriteLine($"Details: {details}"); + Terminal.Error($"Details: {details}"); } - Console.Error.WriteLine("This looks like a bug in gclo; please report it at https://github.com/KofTwentyTwo/gclo/issues."); + Terminal.Error("This looks like a bug in gclo; please report it at https://github.com/KofTwentyTwo/gclo/issues."); return ExitCodes.Unexpected; } @@ -160,12 +160,12 @@ internal static class Program case "accounts": return Task.FromResult(AccountsCommand.Run(rest)); case "--help" or "-h": - Console.Out.WriteLine(RootHelp); + Terminal.OutVerbatim(RootHelp); return Task.FromResult(ExitCodes.Success); case "help": if(rest.Length == 0) { - Console.Out.WriteLine(RootHelp); + Terminal.OutVerbatim(RootHelp); return Task.FromResult(ExitCodes.Success); } // 'gclo help sync' means 'gclo sync --help'; an unknown name is a usage error. @@ -173,7 +173,7 @@ internal static class Program ? RunAsync([rest[0], "--help"], cancellationToken) : throw new CliUsageException($"Unknown command '{rest[0]}'."); case "--version": - Console.Out.WriteLine(Version()); + Terminal.OutVerbatim(Version()); return Task.FromResult(ExitCodes.Success); default: throw new CliUsageException($"Unknown command '{command}'."); diff --git a/gclo.Cli/ProgressPrinter.cs b/gclo.Cli/ProgressPrinter.cs index 8b1c532..5348b7a 100644 --- a/gclo.Cli/ProgressPrinter.cs +++ b/gclo.Cli/ProgressPrinter.cs @@ -94,7 +94,7 @@ internal sealed class ProgressPrinter : IProgress switch(_mode) { case OutputMode.Text or OutputMode.Quiet: - Console.Error.WriteLine($"{failure.Repo} Failed {failure.Error}"); + Terminal.Error($"{failure.Repo} Failed {failure.Error}"); break; case OutputMode.JsonLines: WriteProgressLine(value.RepoName, value.Status, failure.Error); @@ -108,7 +108,7 @@ internal sealed class ProgressPrinter : IProgress switch(_mode) { case OutputMode.Text: - Console.Out.WriteLine($"{value.RepoName} {value.Status}"); + Terminal.Out($"{value.RepoName} {value.Status}"); break; case OutputMode.JsonLines: WriteProgressLine(value.RepoName, value.Status, null); @@ -122,7 +122,7 @@ internal sealed class ProgressPrinter : IProgress private static void WriteProgressLine(string repo, SyncStatus status, string? error) - => Console.Out.WriteLine(JsonSerializer.Serialize( + => Terminal.OutVerbatim(JsonSerializer.Serialize( new SyncProgressLine("progress", repo, status.ToString(), error), CliJsonContext.Default.SyncProgressLine)); diff --git a/gclo.Cli/ReposCommand.cs b/gclo.Cli/ReposCommand.cs index f480461..a2b00c3 100644 --- a/gclo.Cli/ReposCommand.cs +++ b/gclo.Cli/ReposCommand.cs @@ -73,7 +73,7 @@ internal static class ReposCommand switch(reader.Current) { case "--help" or "-h": - Console.Out.WriteLine(HelpText); + Terminal.OutVerbatim(HelpText); return ExitCodes.Success; case "--org": org = reader.RequireValue(); @@ -132,13 +132,13 @@ internal static class ReposCommand IReadOnlyList summaries = repositories .Select(r => new RepoSummary(r.Name, r.DefaultBranch, r.IsArchived, r.CloneUrl)) .ToList(); - Console.Out.WriteLine(JsonSerializer.Serialize(summaries, CliJsonContext.Default.IReadOnlyListRepoSummary)); + Terminal.OutVerbatim(JsonSerializer.Serialize(summaries, CliJsonContext.Default.IReadOnlyListRepoSummary)); return ExitCodes.Success; } if(repositories.Count == 0) { - Console.Error.WriteLine(listed == 0 + Terminal.Error(listed == 0 ? $"No repositories visible in '{org}'." : $"No repositories of '{org}' match the filters ({listed} listed)."); return ExitCodes.Success; @@ -149,7 +149,7 @@ internal static class ReposCommand foreach(RepoDescriptor repo in repositories) { string branch = (repo.DefaultBranch ?? "-").PadRight(branchWidth); - Console.Out.WriteLine( + Terminal.Out( $"{repo.Name.PadRight(nameWidth)} {branch}" + (repo.IsArchived ? " archived" : "")); } return ExitCodes.Success; diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs index e4b2b27..70406b3 100644 --- a/gclo.Cli/SyncCommand.cs +++ b/gclo.Cli/SyncCommand.cs @@ -149,7 +149,7 @@ internal static class SyncCommand switch(reader.Current) { case "--help" or "-h": - Console.Out.WriteLine(HelpText); + Terminal.OutVerbatim(HelpText); return null; case "--org": options.Org = reader.RequireValue(); @@ -232,11 +232,11 @@ internal static class SyncCommand var result = new SyncJsonResult( "summary", summary.Total, summary.Cloned, summary.Updated, summary.Failed, summary.Canceled, summary.WasCanceled, printer.Failures, printer.Sanitized); - Console.Out.WriteLine(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult)); + Terminal.OutVerbatim(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult)); } else { - Console.Out.WriteLine(summaryLine); + Terminal.Out(summaryLine); } if(account is not null) @@ -440,16 +440,16 @@ internal static class SyncCommand if(json) { - Console.Out.WriteLine(JsonSerializer.Serialize( + Terminal.OutVerbatim(JsonSerializer.Serialize( (IReadOnlyList)entries, CliJsonContext.Default.IReadOnlyListDryRunEntry)); return ExitCodes.Success; } foreach(DryRunEntry entry in entries) { - Console.Out.WriteLine($"{entry.Repo} would {entry.Action}"); + Terminal.Out($"{entry.Repo} would {entry.Action}"); } - Console.Out.WriteLine($"Dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}."); + Terminal.Out($"Dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}."); return ExitCodes.Success; } @@ -499,7 +499,7 @@ internal static class SyncCommand catch(Exception ex) { log.Error($"could not record the sync result on account '{account.Name}': {ex.Message}", ex); - Console.Error.WriteLine( + Terminal.Error( $"Warning: could not record the sync result on account '{account.Name}': {ex.Message}"); } } @@ -546,7 +546,7 @@ internal static class SyncCommand string detail = DescribeRecovery(recovery); if(announce) { - Console.Error.WriteLine( + Terminal.Error( $"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, " + $"{recovery.SkippedPaths.Count} skipped: {detail}"); } @@ -703,11 +703,11 @@ internal static class SyncCommand // the first three offending paths. foreach(InvalidPathInfo info in value.InvalidPaths.Take(MaxListedPaths)) { - Console.Error.WriteLine($"{value.RepoName} {info.RepoPath} ({info.Reason})"); + Terminal.Error($"{value.RepoName} {info.RepoPath} ({info.Reason})"); } if(value.InvalidPaths.Count > MaxListedPaths) { - Console.Error.WriteLine( + Terminal.Error( $"{value.RepoName} ... and {value.InvalidPaths.Count - MaxListedPaths} more invalid paths"); } } diff --git a/gclo.Cli/Terminal.cs b/gclo.Cli/Terminal.cs new file mode 100644 index 0000000..41ae5be --- /dev/null +++ b/gclo.Cli/Terminal.cs @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text; +using gclo.Engine; + + +namespace gclo.Cli; + + +/// +/// The one place the command line writes to standard output and standard error. Text +/// that is assembled at run time can quote a repository, a server, or a file on disk, +/// so it goes out through and , which make it +/// safe for a terminal. Only text that is fixed in the program (help, the version) or +/// produced by the JSON encoder (which emits ASCII and escapes everything else) is +/// written as it is, through (#143). +/// +/// +/// Safe for a terminal means two things. Characters that would break a line, steer +/// the terminal, or reorder or hide the text around them are shown in visible form +/// (see ). And because what reaches the terminal is bytes in +/// the console's code page, a character that code page cannot represent is shown the +/// same way: a legacy code page otherwise "best-fits" it to a different byte, and for +/// some ordinary printable characters that byte is a control character (on code page +/// 437, a left arrow becomes ESC). +/// +internal static class Terminal +{ + /// Writes one line of run-time text to standard output, made safe for the terminal. + public static void Out(string text) => Console.Out.WriteLine(Safe(text, Console.Out.Encoding)); + + + + /// Writes one line of run-time text to standard error, made safe for the terminal. + public static void Error(string text) => Console.Error.WriteLine(Safe(text, Console.Error.Encoding)); + + + + /// + /// Writes text to standard output exactly as given. For text fixed in the program + /// and for JSON documents only; never for anything that quotes run-time data. + /// + public static void OutVerbatim(string text) => Console.Out.WriteLine(text); + + + + /// Writes a fixed prompt to standard error, without a line break after it. + public static void ErrorPrompt(string fixedText) => Console.Error.Write(fixedText); + + + + /// Ends the current line on standard error. + public static void ErrorLineBreak() => Console.Error.WriteLine(); + + + + /// + /// as one line that is safe to send to a terminal whose + /// output encoding is . + /// + internal static string Safe(string? text, Encoding encoding) + { + ArgumentNullException.ThrowIfNull(encoding); + + string visible = DisplayText.Escape(text); + if(IsUnicode(encoding)) + { + return visible; + } + + StringBuilder? builder = null; + int index = 0; + foreach(Rune rune in visible.EnumerateRunes()) + { + int length = rune.Utf16SequenceLength; + if(rune.IsAscii || RoundTrips(encoding, visible.AsSpan(index, length))) + { + builder?.Append(visible, index, length); + } + else + { + builder ??= new StringBuilder(visible.Length + 16).Append(visible, 0, index); + for(int unit = 0; unit < length; unit++) + { + DisplayText.AppendVisible(builder, visible[index + unit]); + } + } + index += length; + } + + return builder?.ToString() ?? visible; + } + + + + /// UTF-8, UTF-16 and UTF-32 represent every character; nothing is best-fitted. + private static bool IsUnicode(Encoding encoding) + => encoding.CodePage is 65001 or 1200 or 1201 or 12000 or 12001; + + + + /// True when survives encoding and decoding unchanged. + private static bool RoundTrips(Encoding encoding, ReadOnlySpan character) + { + Span bytes = stackalloc byte[16]; + Span back = stackalloc char[8]; + int byteCount = encoding.GetBytes(character, bytes); + int charCount = encoding.GetChars(bytes[..byteCount], back); + return back[..charCount].SequenceEqual(character); + } +} diff --git a/gclo.Cli/TokenOptions.cs b/gclo.Cli/TokenOptions.cs index 72ba054..0bc97de 100644 --- a/gclo.Cli/TokenOptions.cs +++ b/gclo.Cli/TokenOptions.cs @@ -173,9 +173,9 @@ internal sealed class TokenOptions // Interactive use: prompt on stderr (redirected stdout stays clean) and read // WITHOUT echo, like git/gh credential prompts — an echoed token would sit on // screen and in the terminal scrollback. - Console.Error.Write("Token: "); + Terminal.ErrorPrompt("Token: "); string line = ReadLineNoEcho().Trim(); - Console.Error.WriteLine(); + Terminal.ErrorLineBreak(); return line; } diff --git a/gclo.Engine.Tests/ActivityLogTests.cs b/gclo.Engine.Tests/ActivityLogTests.cs index 7d353c1..b35dd11 100644 --- a/gclo.Engine.Tests/ActivityLogTests.cs +++ b/gclo.Engine.Tests/ActivityLogTests.cs @@ -43,6 +43,12 @@ public sealed class ActivityLogTests : IDisposable + /// The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits. + private static string Visible(int unit) + => "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture); + + + // ---------------------------------------------------------------- line format [Fact] @@ -75,6 +81,51 @@ public sealed class ActivityLogTests : IDisposable + [Fact] + public void Message_WithLineBreaksAndEscapeCharacters_IsStillOneEntryOnOneLine() + { + var log = new FileActivityLog(_root); + // What a repository can put into a message: a file path with a newline that + // imitates the start of another entry, and a terminal escape sequence. + string forged = "repo failed: bad\n2026-01-01 00:00:00.000 [INFO] all repositories synced" + (char)0x1B + "[2J"; + + log.Error(forged); + + string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath)); + Assert.Matches(s_linePattern, line); + Assert.Contains("[ERROR] repo failed: bad", line); + Assert.Contains(Visible(0x0A) + "2026-01-01 00:00:00.000 [INFO] all repositories synced", line); + Assert.EndsWith(Visible(0x1B) + "[2J", line, StringComparison.Ordinal); + Assert.DoesNotContain((char)0x1B, line); + } + + + + [Fact] + public void Exception_WhoseTextImitatesAnEntry_CannotStartALineInTheFirstColumn() + { + var log = new FileActivityLog(_root); + var exception = new InvalidOperationException( + "bad path 'a\n2026-01-01 00:00:00.000 [INFO] forged entry' in tree" + (char)0x1B + "[0m"); + + log.Error("sync blew up", exception); + log.Info("next entry"); + + string[] lines = File.ReadAllLines(log.CurrentLogFilePath); + // Exactly two lines look like entries: the real ones. + Assert.Equal(2, lines.Count(l => s_linePattern.IsMatch(l))); + Assert.Matches(s_linePattern, lines[0]); + Assert.Matches(s_linePattern, lines[^1]); + Assert.All(lines[1..^1], l => Assert.StartsWith(" ", l, StringComparison.Ordinal)); + // The indent is what keeps a continuation line from matching an entry's start, + // for this pattern and for the Log window's filter alike. + Assert.All(lines[1..^1], l => Assert.DoesNotMatch(s_linePattern, l)); + Assert.Contains(lines, l => l.Contains("2026-01-01 00:00:00.000 [INFO] forged entry", StringComparison.Ordinal)); + Assert.DoesNotContain(lines, l => l.Contains((char)0x1B)); + } + + + [Fact] public void Error_WithException_AppendsExceptionTextOnFollowingLines() { diff --git a/gclo.Engine.Tests/DisplayTextTests.cs b/gclo.Engine.Tests/DisplayTextTests.cs new file mode 100644 index 0000000..23cafe7 --- /dev/null +++ b/gclo.Engine.Tests/DisplayTextTests.cs @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Globalization; +using System.Text; + + +namespace gclo.Engine.Tests; + + +/// +/// turns characters that would break a log line, or reorder +/// or hide the text around them, into visible text, and leaves everything else alone +/// (#143). +/// +public sealed class DisplayTextTests +{ + private const char Escape = (char)0x1B; + + + + [Theory] + [InlineData("plain text")] + [InlineData("src/app/Main.cs")] + [InlineData("café/日本語.txt")] + [InlineData("emoji 😀 stays")] + [InlineData(@"C:\Users\someone\src\repo")] + [InlineData(" ")] + public void Escape_OrdinaryText_ComesBackUnchanged_AsTheSameString(string text) + { + Assert.Same(text, DisplayText.Escape(text)); + } + + + + [Fact] + public void Escape_TextThatNeedsJoiners_ComesBackUnchanged() + { + // A family emoji is three people held together by zero-width joiners, and + // Persian uses the zero-width non-joiner inside ordinary words. + string family = char.ConvertFromUtf32(0x1F468) + (char)0x200D + char.ConvertFromUtf32(0x1F469) + (char)0x200D + char.ConvertFromUtf32(0x1F467); + string persian = "" + (char)0x0645 + (char)0x06CC + (char)0x200C + (char)0x062E; + + Assert.Same(family, DisplayText.Escape(family)); + Assert.Same(persian, DisplayText.Escape(persian)); + } + + + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Escape_NothingToShow_IsEmpty(string? text) + { + Assert.Equal("", DisplayText.Escape(text)); + } + + + + [Fact] + public void Escape_ControlCharacters_BecomeVisible_OnOneLine() + { + Assert.Equal("a" + Visible(0x0A) + "b", DisplayText.Escape("a\nb")); + Assert.Equal("a" + Visible(0x0D) + Visible(0x0A) + "b", DisplayText.Escape("a\r\nb")); + Assert.Equal("col1" + Visible(0x09) + "col2", DisplayText.Escape("col1\tcol2")); + Assert.Equal(Visible(0x1B) + "[2Jwiped", DisplayText.Escape(Escape + "[2Jwiped")); + Assert.Equal("bell" + Visible(0x07), DisplayText.Escape("bell" + (char)0x07)); + Assert.Equal("nul" + Visible(0x00), DisplayText.Escape("nul" + (char)0x00)); + Assert.Equal("del" + Visible(0x7F), DisplayText.Escape("del" + (char)0x7F)); + Assert.Equal("nel" + Visible(0x85), DisplayText.Escape("nel" + (char)0x85)); + Assert.Equal("csi" + Visible(0x9B) + "31m", DisplayText.Escape("csi" + (char)0x9B + "31m")); + } + + + + [Theory] + [InlineData(0x2028)] // line separator + [InlineData(0x2029)] // paragraph separator + [InlineData(0x200E)] // left-to-right mark + [InlineData(0x200F)] // right-to-left mark + [InlineData(0x061C)] // Arabic letter mark + [InlineData(0x202A)] // left-to-right embedding + [InlineData(0x202E)] // right-to-left override + [InlineData(0x2066)] // left-to-right isolate + [InlineData(0x2069)] // pop directional isolate + [InlineData(0x206A)] // deprecated format characters + [InlineData(0x200B)] // zero-width space + [InlineData(0x2060)] // word joiner + [InlineData(0xFEFF)] // zero-width no-break space (byte order mark) + [InlineData(0x00AD)] // soft hyphen + public void Escape_SeparatorsDirectionAndInvisibleCharacters_BecomeVisible(int code) + { + string text = "invoice" + (char)code + "fdp.exe"; + + string escaped = DisplayText.Escape(text); + + Assert.Equal("invoice" + Visible(code) + "fdp.exe", escaped); + Assert.DoesNotContain((char)code, escaped); + } + + + + [Theory] + [InlineData(0x2027)] // hyphenation point: punctuation + [InlineData(0x202F)] // narrow no-break space: a space + [InlineData(0x2065)] // unassigned + [InlineData(0x200C)] // zero-width non-joiner + [InlineData(0x200D)] // zero-width joiner + [InlineData(0xFE0F)] // variation selector: a mark, used by emoji + [InlineData(0xE000)] // private use + public void Escape_CharactersThatOrdinaryTextUses_AreLeftAlone(int code) + { + string text = "a" + (char)code + "b"; + + Assert.Same(text, DisplayText.Escape(text)); + } + + + + [Fact] + public void Escape_TagCharacters_WhichAreInvisibleAndCanCarryHiddenText_BecomeVisible_AsTheirTwoCodeUnits() + { + string tag = char.ConvertFromUtf32(0xE0041); // "tag latin capital letter a" + + string escaped = DisplayText.Escape("a" + tag + "b"); + + Assert.Equal("a" + Visible(tag[0]) + Visible(tag[1]) + "b", escaped); + } + + + + [Fact] + public void Escape_HalfOfASurrogatePair_BecomesVisible_WhereverItStands() + { + char high = char.ConvertFromUtf32(0x1F600)[0]; + char low = char.ConvertFromUtf32(0x1F600)[1]; + + Assert.Equal("a" + Visible(high) + "b", DisplayText.Escape("a" + high + "b")); + Assert.Equal("a" + Visible(low) + "b", DisplayText.Escape("a" + low + "b")); + Assert.Equal("a" + Visible(high), DisplayText.Escape("a" + high)); + Assert.Equal(Visible(low) + Visible(high), DisplayText.Escape("" + low + high)); + } + + + + [Fact] + public void Escape_KeepsEverythingBeforeAndAfterTheFirstCharacterItChanges() + { + string emoji = char.ConvertFromUtf32(0x1F600); + string text = "start " + emoji + " " + Escape + " middle \n " + emoji + " end"; + + Assert.Equal( + "start " + emoji + " " + Visible(0x1B) + " middle " + Visible(0x0A) + " " + emoji + " end", + DisplayText.Escape(text)); + } + + + + [Fact] + public void EscapeLines_KeepsTheLines_IndentsEachOne_AndEscapesWithinThem() + { + string text = "first\nsecond " + Escape + "[0m\r\nthird\rfourth\n"; + + string block = DisplayText.EscapeLines(text, " "); + + Assert.Equal( + new[] { " first", " second " + Visible(0x1B) + "[0m", " third", " fourth", " " }, + block.Split(Environment.NewLine)); + } + + + + [Fact] + public void EscapeLines_NoIndent_StillOneEscapedLinePerLine() + { + Assert.Equal("a" + Environment.NewLine + "b", DisplayText.EscapeLines("a\nb", "")); + } + + + + [Theory] + [InlineData(null)] + [InlineData("")] + public void EscapeLines_NothingToShow_IsEmpty(string? text) + { + Assert.Equal("", DisplayText.EscapeLines(text, " ")); + } + + + + [Fact] + public void EscapeLines_NullIndent_IsACallerError() + { + Assert.Throws(() => DisplayText.EscapeLines("text", null!)); + } + + + + [Fact] + public void AppendVisible_WritesBackslashUAndFourUpperCaseHexDigits() + { + var builder = new StringBuilder("x"); + + DisplayText.AppendVisible(builder, (char)0x1B); + DisplayText.AppendVisible(builder, (char)0xFEFF); + DisplayText.AppendVisible(builder, (char)0x000A); + + Assert.Equal("x" + Visible(0x1B) + Visible(0xFEFF) + Visible(0x0A), builder.ToString()); + Assert.Equal(6, Visible(0x1B).Length); + Assert.Throws(() => DisplayText.AppendVisible(null!, 'a')); + } + + + + /// The expected visible form: a backslash, "u", and four upper-case hex digits. + private static string Visible(int code) + => "\\" + "u" + code.ToString("X4", CultureInfo.InvariantCulture); +} diff --git a/gclo.Engine/DisplayText.cs b/gclo.Engine/DisplayText.cs new file mode 100644 index 0000000..87fde50 --- /dev/null +++ b/gclo.Engine/DisplayText.cs @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Globalization; +using System.Text; + + +namespace gclo.Engine; + + +/// +/// Makes text that came from somewhere else safe to put in a log line. Repository +/// content reaches the log: a file path in a git tree may contain a newline, an escape +/// character, or a character that reorders or hides the text around it, and such a +/// path is exactly what gclo reports when it cannot be created on Windows. Left alone, +/// a newline starts what reads as a new log entry (#143). +/// +/// +/// +/// What becomes visible is decided per code point, by Unicode category: control +/// characters (C0, DEL, C1), line and paragraph separators, format characters (the +/// text-direction marks, embeddings, overrides and isolates; zero-width and other +/// invisible characters; tag characters), and a surrogate that has no partner. The +/// zero-width joiner and non-joiner stay: emoji sequences and several scripts are +/// written with them. Everything else, in any script, is left as it is. +/// +/// +/// The visible form is a backslash, the letter u and four upper-case hexadecimal +/// digits, once per UTF-16 code unit. A path that already contains those six +/// characters reads the same way; the form is for people, and nothing reads it back. +/// +/// +/// A terminal needs more than this: what reaches it is bytes in the console's code +/// page, and a legacy code page turns some ordinary characters into control bytes. +/// The command line writes through its own layer for that. +/// +/// +public static class DisplayText +{ + private const string HexDigits = "0123456789ABCDEF"; + + private static readonly string[] s_lineBreaks = ["\r\n", "\n", "\r"]; + + + + /// + /// with every character described in the type remarks + /// replaced by its visible form; the result is always one line. Text that needs no + /// change comes back as the same string. Null gives an empty string. + /// + public static string Escape(string? text) + { + if(string.IsNullOrEmpty(text)) + { + return ""; + } + + int first = 0; + while(first < text.Length) + { + if(IsUnsafeAt(text, first, out int length)) + { + break; + } + first += length; + } + + if(first == text.Length) + { + return text; + } + + var builder = new StringBuilder(text.Length + 16); + builder.Append(text, 0, first); + for(int i = first; i < text.Length;) + { + bool unsafeHere = IsUnsafeAt(text, i, out int length); + for(int unit = 0; unit < length; unit++) + { + if(unsafeHere) + { + AppendVisible(builder, text[i + unit]); + } + else + { + builder.Append(text[i + unit]); + } + } + i += length; + } + + return builder.ToString(); + } + + + + /// + /// as a block of lines: its own line breaks are kept, every + /// line is escaped like and starts with + /// . With a non-empty indent no line of the block can be + /// taken for something that starts in the first column, such as a log entry's + /// timestamp. Null or empty text gives an empty string. + /// + public static string EscapeLines(string? text, string indent) + { + ArgumentNullException.ThrowIfNull(indent); + if(string.IsNullOrEmpty(text)) + { + return ""; + } + + return string.Join( + Environment.NewLine, + text.Split(s_lineBreaks, StringSplitOptions.None).Select(line => indent + Escape(line))); + } + + + + /// + /// Appends the visible form of one UTF-16 code unit to : + /// a backslash, the letter u, and four upper-case hexadecimal digits. + /// + public static void AppendVisible(StringBuilder builder, char unit) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Append('\\').Append('u'); + for(int shift = 12; shift >= 0; shift -= 4) + { + builder.Append(HexDigits[(unit >> shift) & 0xF]); + } + } + + + + /// + /// Whether the code point that starts at must be made + /// visible, and how many UTF-16 code units it takes (two for a surrogate pair). + /// + private static bool IsUnsafeAt(string text, int index, out int length) + { + char c = text[index]; + if(char.IsHighSurrogate(c) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1])) + { + length = 2; + return IsUnsafeCategory(CharUnicodeInfo.GetUnicodeCategory(char.ConvertToUtf32(c, text[index + 1]))); + } + + length = 1; + if(char.IsSurrogate(c)) + { + return true; // half of a pair: not a character, and writers replace it with something else + } + + // The zero-width non-joiner and joiner (0x200C, 0x200D) are format characters + // that ordinary text needs. + return c is not ((char)0x200C or (char)0x200D) && IsUnsafeCategory(CharUnicodeInfo.GetUnicodeCategory(c)); + } + + + + private static bool IsUnsafeCategory(UnicodeCategory category) + => category is UnicodeCategory.Control + or UnicodeCategory.Format + or UnicodeCategory.LineSeparator + or UnicodeCategory.ParagraphSeparator; +} diff --git a/gclo.Engine/FileActivityLog.cs b/gclo.Engine/FileActivityLog.cs index c425732..e318f86 100644 --- a/gclo.Engine/FileActivityLog.cs +++ b/gclo.Engine/FileActivityLog.cs @@ -19,6 +19,9 @@ namespace gclo.Engine; /// public sealed class FileActivityLog : IActivityLog { + /// What every line of an exception's text starts with, under its entry. + private const string ContinuationIndent = " "; + private readonly Lock _gate = new(); @@ -73,10 +76,17 @@ public sealed class FileActivityLog : IActivityLog string timestamp = DateTime.Now.ToString( "yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); - string entry = $"{timestamp} [{level}] {message}"; + /////////////////////////////////////////////////////////////////////////// + // An entry is one line that starts with its timestamp; readers (the Log // + // window's error filter among them) rely on that. Messages carry text // + // from repositories, so the message is escaped onto one line, and an // + // exception's lines are indented: nothing in them can start a line that // + // looks like another entry (#143). // + /////////////////////////////////////////////////////////////////////////// + string entry = $"{timestamp} [{level}] {DisplayText.Escape(message)}"; if(exception is not null) { - entry += Environment.NewLine + exception; + entry += Environment.NewLine + DisplayText.EscapeLines(exception.ToString(), ContinuationIndent); } File.AppendAllText(CurrentLogFilePath, entry + Environment.NewLine);