fix(log): show control characters from repository content as visible text #144
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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://<host>.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) |
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
/// <summary>The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits.</summary>
|
||||
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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits.</summary>
|
||||
private static string Visible(int unit)
|
||||
=> "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Filters_NarrowTheListing_AndAnEmptyResultSaysWhy()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Terminal"/> 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).
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() => 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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
|
||||
|
||||
|
||||
/// <summary>The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits.</summary>
|
||||
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();
|
||||
}
|
||||
+10
-10
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
+11
-11
@@ -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}'.");
|
||||
|
||||
@@ -94,7 +94,7 @@ internal sealed class ProgressPrinter : IProgress<RepoProgress>
|
||||
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<RepoProgress>
|
||||
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<RepoProgress>
|
||||
|
||||
|
||||
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));
|
||||
|
||||
|
||||
|
||||
@@ -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<RepoSummary> 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;
|
||||
|
||||
+10
-10
@@ -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<DryRunEntry>)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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text;
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Out"/> and <see cref="Error"/>, 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 <see cref="OutVerbatim"/> (#143).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="DisplayText"/>). 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).
|
||||
/// </remarks>
|
||||
internal static class Terminal
|
||||
{
|
||||
/// <summary>Writes one line of run-time text to standard output, made safe for the terminal.</summary>
|
||||
public static void Out(string text) => Console.Out.WriteLine(Safe(text, Console.Out.Encoding));
|
||||
|
||||
|
||||
|
||||
/// <summary>Writes one line of run-time text to standard error, made safe for the terminal.</summary>
|
||||
public static void Error(string text) => Console.Error.WriteLine(Safe(text, Console.Error.Encoding));
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static void OutVerbatim(string text) => Console.Out.WriteLine(text);
|
||||
|
||||
|
||||
|
||||
/// <summary>Writes a fixed prompt to standard error, without a line break after it.</summary>
|
||||
public static void ErrorPrompt(string fixedText) => Console.Error.Write(fixedText);
|
||||
|
||||
|
||||
|
||||
/// <summary>Ends the current line on standard error.</summary>
|
||||
public static void ErrorLineBreak() => Console.Error.WriteLine();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="text"/> as one line that is safe to send to a terminal whose
|
||||
/// output encoding is <paramref name="encoding"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>UTF-8, UTF-16 and UTF-32 represent every character; nothing is best-fitted.</summary>
|
||||
private static bool IsUnicode(Encoding encoding)
|
||||
=> encoding.CodePage is 65001 or 1200 or 1201 or 12000 or 12001;
|
||||
|
||||
|
||||
|
||||
/// <summary>True when <paramref name="character"/> survives encoding and decoding unchanged.</summary>
|
||||
private static bool RoundTrips(Encoding encoding, ReadOnlySpan<char> character)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
Span<char> back = stackalloc char[8];
|
||||
int byteCount = encoding.GetBytes(character, bytes);
|
||||
int charCount = encoding.GetChars(bytes[..byteCount], back);
|
||||
return back[..charCount].SequenceEqual(character);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ public sealed class ActivityLogTests : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits.</summary>
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="DisplayText"/> 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).
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() => 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<ArgumentNullException>(() => DisplayText.AppendVisible(null!, 'a'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The expected visible form: a backslash, "u", and four upper-case hex digits.</summary>
|
||||
private static string Visible(int code)
|
||||
=> "\\" + "u" + code.ToString("X4", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DisplayText
|
||||
{
|
||||
private const string HexDigits = "0123456789ABCDEF";
|
||||
|
||||
private static readonly string[] s_lineBreaks = ["\r\n", "\n", "\r"];
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="text"/> 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="text"/> as a block of lines: its own line breaks are kept, every
|
||||
/// line is escaped like <see cref="Escape"/> and starts with
|
||||
/// <paramref name="indent"/>. 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.
|
||||
/// </summary>
|
||||
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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Appends the visible form of one UTF-16 code unit to <paramref name="builder"/>:
|
||||
/// a backslash, the letter u, and four upper-case hexadecimal digits.
|
||||
/// </summary>
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether the code point that starts at <paramref name="index"/> must be made
|
||||
/// visible, and how many UTF-16 code units it takes (two for a surrogate pair).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ namespace gclo.Engine;
|
||||
/// </summary>
|
||||
public sealed class FileActivityLog : IActivityLog
|
||||
{
|
||||
/// <summary>What every line of an exception's text starts with, under its entry.</summary>
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user