fix(cli): one safe output point; escape by code point and console encoding
Follow-up to the independent review of this branch. - What reaches a terminal is bytes in the console's code page, and a legacy code page turns some printable characters into control bytes (on code page 437 a left arrow becomes ESC), after the escaping has passed them. The CLI now writes every line through one place, Terminal, which also shows any character the console's encoding cannot represent in visible form. A test encodes every character of the basic plane for code pages 437, 850, 1252 and 932 and fails on any control byte. - That one place replaces the per-call escapes, so the messages the first commit missed are covered too: unexpected and usage errors, account, organization and target names, the sync summary. A test fails if any other CLI file writes to the console. - DisplayText decides per code point by Unicode category: control, line and paragraph separator, and format characters (direction marks and overrides including the Arabic letter mark, invisible characters, tag characters) and unpaired surrogates become visible; the zero-width joiner and non-joiner stay, since emoji and several scripts need them. - No allocation per escaped character. - Tests assert the visible form with its backslash. The threat model says what is covered (log and CLI) and what is not (the desktop app's own surfaces, look-alike text). Refs #143 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
1 parent
9c42165334
commit
23d3fdf07b
18 files changed
+660
-123
No files matched your search
+1
-1
@@ -382,7 +382,7 @@ 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. The CLI's text output shows such characters the same way; the JSON modes use JSON's own escaping.
|
||||
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
|
||||
|
||||
|
||||
@@ -96,7 +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 (a file path may contain a newline, an escape sequence, or a text-direction override) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` turns control, separator and direction characters into visible `\uXXXX` text: every log message is one line, an exception's lines are indented under their entry so none can start like one, and the CLI's text modes print repository names, error text and paths through it; the JSON modes rely on the JSON encoder (#143) | 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
|
||||
@@ -119,4 +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 is shown with control, separator and text-direction characters made visible, in the activity log and in the CLI's text output (#143) |
|
||||
| 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()
|
||||
{
|
||||
|
||||
@@ -56,14 +56,14 @@ public sealed class ProgressPrinterTests
|
||||
|
||||
string line = Assert.Single(Lines(console.Error));
|
||||
Assert.StartsWith("repo Failed invalid path 'a", line, StringComparison.Ordinal);
|
||||
Assert.Contains("u000Aother Done", line);
|
||||
Assert.Contains("u001B[2J'", line);
|
||||
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.Contains("u001B[31mird Cloning", Assert.Single(Lines(console.Out)));
|
||||
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);
|
||||
@@ -148,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(DisplayText.Escape(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,9 +94,7 @@ internal sealed class ProgressPrinter : IProgress<RepoProgress>
|
||||
switch(_mode)
|
||||
{
|
||||
case OutputMode.Text or OutputMode.Quiet:
|
||||
// Error text can quote repository content (a file path with a
|
||||
// newline or an escape sequence); the terminal gets it visibly (#143).
|
||||
Console.Error.WriteLine($"{DisplayText.Escape(failure.Repo)} Failed {DisplayText.Escape(failure.Error)}");
|
||||
Terminal.Error($"{failure.Repo} Failed {failure.Error}");
|
||||
break;
|
||||
case OutputMode.JsonLines:
|
||||
WriteProgressLine(value.RepoName, value.Status, failure.Error);
|
||||
@@ -110,7 +108,7 @@ internal sealed class ProgressPrinter : IProgress<RepoProgress>
|
||||
switch(_mode)
|
||||
{
|
||||
case OutputMode.Text:
|
||||
Console.Out.WriteLine($"{DisplayText.Escape(value.RepoName)} {value.Status}");
|
||||
Terminal.Out($"{value.RepoName} {value.Status}");
|
||||
break;
|
||||
case OutputMode.JsonLines:
|
||||
WriteProgressLine(value.RepoName, value.Status, null);
|
||||
@@ -124,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,28 +132,25 @@ 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;
|
||||
}
|
||||
|
||||
// Names and branches come from the server; the terminal gets them visibly (#143).
|
||||
var rows = repositories
|
||||
.Select(r => (Name: DisplayText.Escape(r.Name), Branch: DisplayText.Escape(r.DefaultBranch ?? "-"), r.IsArchived))
|
||||
.ToList();
|
||||
int nameWidth = rows.Max(r => r.Name.Length);
|
||||
int branchWidth = rows.Max(r => r.Branch.Length);
|
||||
foreach((string name, string branch, bool isArchived) in rows)
|
||||
int nameWidth = repositories.Max(r => r.Name.Length);
|
||||
int branchWidth = repositories.Max(r => (r.DefaultBranch ?? "-").Length);
|
||||
foreach(RepoDescriptor repo in repositories)
|
||||
{
|
||||
Console.Out.WriteLine(
|
||||
$"{name.PadRight(nameWidth)} {branch.PadRight(branchWidth)}" + (isArchived ? " archived" : ""));
|
||||
string branch = (repo.DefaultBranch ?? "-").PadRight(branchWidth);
|
||||
Terminal.Out(
|
||||
$"{repo.Name.PadRight(nameWidth)} {branch}" + (repo.IsArchived ? " archived" : ""));
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
|
||||
+13
-14
@@ -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($"{DisplayText.Escape(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,9 +546,9 @@ internal static class SyncCommand
|
||||
string detail = DescribeRecovery(recovery);
|
||||
if(announce)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"{DisplayText.Escape(pending.Repo)} Sanitized {recovery.SegmentRenames.Count} renamed, "
|
||||
+ $"{recovery.SkippedPaths.Count} skipped: {DisplayText.Escape(detail)}");
|
||||
Terminal.Error(
|
||||
$"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, "
|
||||
+ $"{recovery.SkippedPaths.Count} skipped: {detail}");
|
||||
}
|
||||
log.Info($"{pending.Repo}: cloned with sanitized paths: {detail}");
|
||||
printer.RecordSanitized(new SyncSanitized(
|
||||
@@ -703,13 +703,12 @@ internal static class SyncCommand
|
||||
// the first three offending paths.
|
||||
foreach(InvalidPathInfo info in value.InvalidPaths.Take(MaxListedPaths))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"{DisplayText.Escape(value.RepoName)} {DisplayText.Escape(info.RepoPath)} ({DisplayText.Escape(info.Reason)})");
|
||||
Terminal.Error($"{value.RepoName} {info.RepoPath} ({info.Reason})");
|
||||
}
|
||||
if(value.InvalidPaths.Count > MaxListedPaths)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"{DisplayText.Escape(value.RepoName)} ... and {value.InvalidPaths.Count - MaxListedPaths} more invalid paths");
|
||||
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]
|
||||
@@ -88,8 +94,8 @@ public sealed class ActivityLogTests : IDisposable
|
||||
string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath));
|
||||
Assert.Matches(s_linePattern, line);
|
||||
Assert.Contains("[ERROR] repo failed: bad", line);
|
||||
Assert.Contains("u000A2026-01-01 00:00:00.000 [INFO] all repositories synced", line);
|
||||
Assert.Contains("u001B[2J", 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);
|
||||
}
|
||||
|
||||
@@ -111,6 +117,9 @@ public sealed class ActivityLogTests : IDisposable
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
* 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 steer a
|
||||
/// terminal into visible text, and leaves everything else alone (#143).
|
||||
/// <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
|
||||
{
|
||||
@@ -21,7 +26,7 @@ public sealed class DisplayTextTests
|
||||
[InlineData("src/app/Main.cs")]
|
||||
[InlineData("café/日本語.txt")]
|
||||
[InlineData("emoji 😀 stays")]
|
||||
[InlineData(@"back\slash and \\u001B written out")]
|
||||
[InlineData(@"C:\Users\someone\src\repo")]
|
||||
[InlineData(" ")]
|
||||
public void Escape_OrdinaryText_ComesBackUnchanged_AsTheSameString(string text)
|
||||
{
|
||||
@@ -30,6 +35,20 @@ public sealed class DisplayTextTests
|
||||
|
||||
|
||||
|
||||
[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("")]
|
||||
@@ -43,14 +62,15 @@ public sealed class DisplayTextTests
|
||||
[Fact]
|
||||
public void Escape_ControlCharacters_BecomeVisible_OnOneLine()
|
||||
{
|
||||
Assert.Equal(Visible("a", 0x0A, "b"), DisplayText.Escape("a\nb"));
|
||||
Assert.Equal(Visible("a", 0x0D, "") + Visible("", 0x0A, "b"), DisplayText.Escape("a\r\nb"));
|
||||
Assert.Equal(Visible("col1", 0x09, "col2"), DisplayText.Escape("col1\tcol2"));
|
||||
Assert.Equal(Visible("", 0x1B, "[2Jwiped"), DisplayText.Escape(Escape + "[2Jwiped"));
|
||||
Assert.Equal(Visible("bell", 0x07, ""), DisplayText.Escape("bell" + (char)0x07));
|
||||
Assert.Equal(Visible("nul", 0x00, ""), DisplayText.Escape("nul" + (char)0x00));
|
||||
Assert.Equal(Visible("del", 0x7F, ""), DisplayText.Escape("del" + (char)0x7F));
|
||||
Assert.Equal(Visible("csi", 0x9B, "31m"), DisplayText.Escape("csi" + (char)0x9B + "31m"));
|
||||
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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -60,29 +80,37 @@ public sealed class DisplayTextTests
|
||||
[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
|
||||
public void Escape_SeparatorsAndTextDirectionCharacters_BecomeVisible(int code)
|
||||
[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(Visible("invoice", code, "fdp.exe"), escaped);
|
||||
Assert.Equal("invoice" + Visible(code) + "fdp.exe", escaped);
|
||||
Assert.DoesNotContain((char)code, escaped);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x2027)]
|
||||
[InlineData(0x202F)]
|
||||
[InlineData(0x2065)]
|
||||
[InlineData(0x206A)]
|
||||
[InlineData(0x200D)] // zero-width joiner: part of ordinary text in many scripts
|
||||
public void Escape_NeighboursOfThoseRanges_AreLeftAlone(int code)
|
||||
[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";
|
||||
|
||||
@@ -92,11 +120,40 @@ public sealed class DisplayTextTests
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Escape_KeepsEverythingBeforeAndAfterTheFirstUnsafeCharacter()
|
||||
public void Escape_TagCharacters_WhichAreInvisibleAndCanCarryHiddenText_BecomeVisible_AsTheirTwoCodeUnits()
|
||||
{
|
||||
string text = "start " + Escape + " middle \n end";
|
||||
string tag = char.ConvertFromUtf32(0xE0041); // "tag latin capital letter a"
|
||||
|
||||
Assert.Equal(Visible("start ", 0x1B, " middle ") + Visible("", 0x0A, " end"), DisplayText.Escape(text));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +161,12 @@ public sealed class DisplayTextTests
|
||||
[Fact]
|
||||
public void EscapeLines_KeepsTheLines_IndentsEachOne_AndEscapesWithinThem()
|
||||
{
|
||||
string text = "first\nsecond " + Escape + "[0m\r\nthird\rfourth";
|
||||
string text = "first\nsecond " + Escape + "[0m\r\nthird\rfourth\n";
|
||||
|
||||
string block = DisplayText.EscapeLines(text, " ");
|
||||
|
||||
Assert.Equal(
|
||||
new[] { " first", " " + Visible("second ", 0x1B, "[0m"), " third", " fourth" },
|
||||
new[] { " first", " second " + Visible(0x1B) + "[0m", " third", " fourth", " " },
|
||||
block.Split(Environment.NewLine));
|
||||
}
|
||||
|
||||
@@ -141,7 +198,23 @@ public sealed class DisplayTextTests
|
||||
|
||||
|
||||
|
||||
[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(string before, int code, string after)
|
||||
=> before + '\\' + "u" + code.ToString("X4", System.Globalization.CultureInfo.InvariantCulture) + after;
|
||||
private static string Visible(int code)
|
||||
=> "\\" + "u" + code.ToString("X4", CultureInfo.InvariantCulture);
|
||||
}
|
||||
+93
-30
@@ -11,25 +11,44 @@ namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Makes text that came from somewhere else safe to put in a log line or on a
|
||||
/// terminal. Repository content reaches both: a file path in a git tree may contain a
|
||||
/// newline, an escape character, or a character that reorders 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, and an escape sequence is
|
||||
/// obeyed by the terminal (#143).
|
||||
/// 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 control character (C0, DEL, C1), line and
|
||||
/// paragraph separator, and bidirectional mark, embedding, override or isolate
|
||||
/// replaced by its visible <c>\uXXXX</c> form; the result is always one line.
|
||||
/// Ordinary text, including any other Unicode, comes back unchanged. Null gives
|
||||
/// an empty string.
|
||||
/// <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)
|
||||
{
|
||||
@@ -39,9 +58,13 @@ public static class DisplayText
|
||||
}
|
||||
|
||||
int first = 0;
|
||||
while(first < text.Length && !IsUnsafe(text[first]))
|
||||
while(first < text.Length)
|
||||
{
|
||||
first++;
|
||||
if(IsUnsafeAt(text, first, out int length))
|
||||
{
|
||||
break;
|
||||
}
|
||||
first += length;
|
||||
}
|
||||
|
||||
if(first == text.Length)
|
||||
@@ -51,17 +74,21 @@ public static class DisplayText
|
||||
|
||||
var builder = new StringBuilder(text.Length + 16);
|
||||
builder.Append(text, 0, first);
|
||||
for(int i = first; i < text.Length; i++)
|
||||
for(int i = first; i < text.Length;)
|
||||
{
|
||||
char c = text[i];
|
||||
if(IsUnsafe(c))
|
||||
bool unsafeHere = IsUnsafeAt(text, i, out int length);
|
||||
for(int unit = 0; unit < length; unit++)
|
||||
{
|
||||
builder.Append("\\u").Append(((int)c).ToString("X4", CultureInfo.InvariantCulture));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(c);
|
||||
if(unsafeHere)
|
||||
{
|
||||
AppendVisible(builder, text[i + unit]);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(text[i + unit]);
|
||||
}
|
||||
}
|
||||
i += length;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
@@ -91,15 +118,51 @@ public static class DisplayText
|
||||
|
||||
|
||||
|
||||
private static bool IsUnsafe(char c)
|
||||
/// <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)
|
||||
{
|
||||
// Compared as numbers: several of these characters end a line as far as a
|
||||
// compiler or an editor is concerned, so they are not written out here.
|
||||
int code = c;
|
||||
return char.IsControl(c)
|
||||
|| code is 0x2028 or 0x2029 // line and paragraph separators
|
||||
or 0x200E or 0x200F // left-to-right and right-to-left marks
|
||||
or (>= 0x202A and <= 0x202E) // embeddings and overrides
|
||||
or (>= 0x2066 and <= 0x2069); // isolates
|
||||
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;
|
||||
}
|
||||
@@ -76,13 +76,13 @@ public sealed class FileActivityLog : IActivityLog
|
||||
|
||||
string timestamp = DateTime.Now.ToString(
|
||||
"yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// 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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user