feat(secrets-cli): bundle export/import flows
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||
|
||||
/// <summary>
|
||||
/// Exports the session's store to a ciphertext-only <see cref="SecretBundle"/> on disk. The operator is
|
||||
/// offered a default filename (<c>secrets-bundle-<target>-<yyyyMMdd>.json</c>) they can accept
|
||||
/// or override, and a confirmation for whether tombstoned rows are included. The bundle carries no
|
||||
/// plaintext — only the encrypted rows and the source KEK id — so it is safe at rest and useless without
|
||||
/// the source key. Requires a KEK-capable session (the shell upgrades a degraded session before dispatch).
|
||||
/// </summary>
|
||||
public sealed class BundleExportFlow : IInteractiveFlow
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Creates the flow.</summary>
|
||||
/// <param name="timeProvider">
|
||||
/// Clock used for the default filename's date stamp and the bundle's <see cref="SecretBundle.ExportedUtc"/>
|
||||
/// timestamp; defaults to <see cref="TimeProvider.System"/>.
|
||||
/// </param>
|
||||
public BundleExportFlow(TimeProvider? timeProvider = null) =>
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Title => "Export bundle (ciphertext-only)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RequiresKek => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(console);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
string defaultPath = DefaultBundleFileName(session.Target.Name, _timeProvider.GetUtcNow());
|
||||
string path = console.Prompt(
|
||||
new TextPrompt<string>("Bundle [green]path[/]:").DefaultValue(defaultPath));
|
||||
|
||||
bool includeDeleted = console.Prompt(
|
||||
new ConfirmationPrompt("Include tombstoned (deleted) rows?") { DefaultValue = false });
|
||||
|
||||
int count;
|
||||
try
|
||||
{
|
||||
count = await new BundleService(_timeProvider)
|
||||
.ExportAsync(session, path, includeDeleted, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
console.MarkupLineInterpolated($"[red]Could not write bundle to '{path}': {ex.Message}[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
console.MarkupLineInterpolated($"[green]Exported {count} row(s) to '{path}'.[/]");
|
||||
}
|
||||
|
||||
// Builds a filesystem-safe default name: secrets-bundle-<target>-<yyyyMMdd>.json, with any character
|
||||
// illegal in a file name collapsed to '-' so the offered default is always a valid path segment.
|
||||
private static string DefaultBundleFileName(string targetName, DateTimeOffset now)
|
||||
{
|
||||
string sanitized = Sanitize(targetName);
|
||||
string date = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
return $"secrets-bundle-{sanitized}-{date}.json";
|
||||
}
|
||||
|
||||
private static string Sanitize(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return "store";
|
||||
}
|
||||
|
||||
char[] invalid = Path.GetInvalidFileNameChars();
|
||||
char[] chars = name.Trim().Select(c => invalid.Contains(c) ? '-' : c).ToArray();
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.MasterKey;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||
|
||||
/// <summary>
|
||||
/// Imports a ciphertext-only <see cref="SecretBundle"/> into the session's store. The bundle is read and
|
||||
/// parsed <b>before</b> any write so its <see cref="SecretBundle.SourceKekId"/> can be inspected: when it
|
||||
/// differs from the session KEK the operator is asked to supply the source key (paste / env var / key
|
||||
/// file) so the rows can be re-wrapped under the session KEK on import — otherwise foreign-KEK rows are
|
||||
/// skipped and reported. The operator may opt into a per-conflict prompt (default off = pure
|
||||
/// last-writer-wins); when on, each name collision renders both rows' timestamp/revision and asks whether
|
||||
/// to take the bundle row. A tally table (Imported / Skipped / Conflicts) closes the flow. Requires a
|
||||
/// KEK-capable session (the shell upgrades a degraded session before dispatch); a pasted key is masked.
|
||||
/// </summary>
|
||||
public sealed class BundleImportFlow : IInteractiveFlow
|
||||
{
|
||||
private const string PasteKeyLabel = "Paste base64 key";
|
||||
private const string EnvVarLabel = "Environment variable";
|
||||
private const string KeyFileLabel = "Key file path";
|
||||
private const string CancelLabel = "Cancel";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Title => "Import bundle";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RequiresKek => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(console);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
string sessionKekId = session.MasterKey?.KekId
|
||||
?? throw new InvalidOperationException("flow requires an unlocked KEK session");
|
||||
|
||||
string path = console.Prompt(new TextPrompt<string>("Bundle [green]path[/]:"));
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
console.MarkupLineInterpolated($"[red]No bundle found at '{path}'.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read + parse FIRST so the source KEK can be inspected before any write is attempted.
|
||||
SecretBundle bundle;
|
||||
try
|
||||
{
|
||||
string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
|
||||
bundle = SecretBundleCodec.Deserialize(json);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or JsonException or IOException)
|
||||
{
|
||||
console.MarkupLineInterpolated($"[red]Could not read bundle '{path}': {ex.Message}[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
// Foreign-KEK detection: a bundle exported under another KEK must be re-wrapped, which needs the
|
||||
// source key. Prompt for it up front; without it every row would be skipped as foreign-KEK.
|
||||
IMasterKeyProvider? sourceKek = null;
|
||||
if (!string.Equals(bundle.SourceKekId, sessionKekId, StringComparison.Ordinal))
|
||||
{
|
||||
console.MarkupLineInterpolated(
|
||||
$"[yellow]This bundle was exported under KEK '{bundle.SourceKekId}', but this session uses '{sessionKekId}'.[/]");
|
||||
console.MarkupLine(
|
||||
"Supply the bundle's source key so its rows can be re-wrapped under this session's KEK.");
|
||||
|
||||
sourceKek = PromptSourceKey(console);
|
||||
if (sourceKek is null)
|
||||
{
|
||||
console.MarkupLine("[yellow]Import cancelled.[/]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool perConflict = console.Prompt(
|
||||
new ConfirmationPrompt("Prompt per conflict? [grey](otherwise last-writer-wins)[/]")
|
||||
{
|
||||
DefaultValue = false,
|
||||
});
|
||||
|
||||
Func<StoredSecret, StoredSecret, bool>? conflictOverride =
|
||||
perConflict ? (existing, incoming) => PromptConflict(console, existing, incoming) : null;
|
||||
|
||||
BundleImportReport report;
|
||||
try
|
||||
{
|
||||
report = await new BundleService()
|
||||
.ImportAsync(session, path, sourceKek, conflictOverride, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
console.MarkupLineInterpolated($"[red]Import failed: {ex.Message}[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
RenderReport(console, report);
|
||||
}
|
||||
|
||||
// Builds the source-KEK provider the operator selects: a pasted base64 key (masked), an environment
|
||||
// variable, or a key file. Cancel (or a bad key) returns null and the caller aborts the import.
|
||||
private static IMasterKeyProvider? PromptSourceKey(IAnsiConsole console)
|
||||
{
|
||||
string choice;
|
||||
try
|
||||
{
|
||||
choice = console.Prompt(new SelectionPrompt<string>()
|
||||
.Title("Provide the bundle's source key")
|
||||
.AddChoices(PasteKeyLabel, EnvVarLabel, KeyFileLabel, CancelLabel));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (choice == CancelLabel)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (choice)
|
||||
{
|
||||
case PasteKeyLabel:
|
||||
string base64 = console.Prompt(new TextPrompt<string>("Paste base64 source KEK:").Secret());
|
||||
return new LiteralMasterKeyProvider(base64);
|
||||
|
||||
case EnvVarLabel:
|
||||
string envVar = console.Prompt(new TextPrompt<string>("Source-key environment variable name:"));
|
||||
return MasterKeyProviderFactory.Create(new MasterKeyOptions
|
||||
{
|
||||
Source = MasterKeySource.Environment,
|
||||
EnvVarName = envVar,
|
||||
});
|
||||
|
||||
default:
|
||||
string filePath = console.Prompt(new TextPrompt<string>("Source-key file path:"));
|
||||
return MasterKeyProviderFactory.Create(new MasterKeyOptions
|
||||
{
|
||||
Source = MasterKeySource.File,
|
||||
FilePath = filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// LiteralMasterKeyProvider rejects a bad paste eagerly; name the problem and abort.
|
||||
console.MarkupLineInterpolated($"[red]Invalid source key: {ex.Message}[/]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Renders a name collision — both rows' last-update timestamp and revision — and asks the operator
|
||||
// whether to take the incoming bundle row (true) or keep the local one (false).
|
||||
private static bool PromptConflict(IAnsiConsole console, StoredSecret existing, StoredSecret incoming)
|
||||
{
|
||||
console.MarkupLineInterpolated($"[yellow]Conflict on '{incoming.Name.Value}':[/]");
|
||||
console.MarkupLineInterpolated(
|
||||
$" local: updated {existing.UpdatedUtc.ToString("O", CultureInfo.InvariantCulture)} revision {existing.Revision}");
|
||||
console.MarkupLineInterpolated(
|
||||
$" bundle: updated {incoming.UpdatedUtc.ToString("O", CultureInfo.InvariantCulture)} revision {incoming.Revision}");
|
||||
return console.Prompt(new ConfirmationPrompt(" Take the bundle row?") { DefaultValue = false });
|
||||
}
|
||||
|
||||
// A compact tally table of the import outcome.
|
||||
private static void RenderReport(IAnsiConsole console, BundleImportReport report)
|
||||
{
|
||||
var table = new Table()
|
||||
.AddColumn("Result")
|
||||
.AddColumn("Rows");
|
||||
|
||||
table.AddRow("Imported", report.Imported.ToString(CultureInfo.InvariantCulture));
|
||||
table.AddRow("Skipped (older)", report.SkippedOlder.ToString(CultureInfo.InvariantCulture));
|
||||
table.AddRow("Skipped (foreign KEK)", report.SkippedForeignKek.ToString(CultureInfo.InvariantCulture));
|
||||
table.AddRow("Conflicts", report.Conflicts.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
using System.Security.Cryptography;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Testing;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||
using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||
using ZB.MOM.WW.Secrets.MasterKey;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the two bundle flows (<see cref="BundleExportFlow"/>, <see cref="BundleImportFlow"/>) directly
|
||||
/// against real temp-SQLite full sessions. Export writes a ciphertext-only bundle to a scripted path;
|
||||
/// import reconciles into a second store — exercising last-writer-wins, the per-row conflict prompt, and
|
||||
/// the foreign-KEK detection that prompts for the source key and re-wraps. Each flow renders to a scripted
|
||||
/// <see cref="TestConsole"/>, so the tests assert on rendered <see cref="TestConsole.Output"/> and on the
|
||||
/// resulting store state — the invariant under test is that a pasted source key never reaches the screen.
|
||||
/// </summary>
|
||||
public sealed class BundleFlowTests : IDisposable
|
||||
{
|
||||
private readonly string _dir =
|
||||
Path.Combine(Path.GetTempPath(), $"zb-secrets-bundleflow-{Guid.NewGuid():N}");
|
||||
|
||||
public BundleFlowTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_dir))
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
private string Db(string name) => Path.Combine(_dir, name);
|
||||
|
||||
private string BundlePath => Path.Combine(_dir, "bundle.json");
|
||||
|
||||
private static TestConsole NewConsole()
|
||||
{
|
||||
var console = (TestConsole)new TestConsole().Interactive();
|
||||
console.Profile.Width = 240; // wide enough that the report table never truncates a cell
|
||||
return console;
|
||||
}
|
||||
|
||||
private static LiteralMasterKeyProvider NewKek(out string base64)
|
||||
{
|
||||
byte[] key = new byte[32];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
base64 = Convert.ToBase64String(key);
|
||||
return new LiteralMasterKeyProvider(base64);
|
||||
}
|
||||
|
||||
// A full (KEK-capable) session on a fresh temp SQLite store, keyed by an operator-override KEK so the
|
||||
// test never depends on ambient environment state.
|
||||
private async Task<SecretsSession> NewSessionAsync(string dbName, LiteralMasterKeyProvider kek)
|
||||
{
|
||||
StoreTarget target = new TargetConfigReader().Manual(Db(dbName), new MasterKeyOptions
|
||||
{
|
||||
Source = MasterKeySource.Environment,
|
||||
EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
|
||||
});
|
||||
|
||||
return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task SealAsync(SecretsSession session, string name, string value)
|
||||
{
|
||||
StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text);
|
||||
await session.Store.UpsertAsync(row, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Writes a row into a store verbatim (bypassing the revision bump) so a test can pin an explicit
|
||||
// UpdatedUtc/Revision — used to make a target row deterministically newer than an incoming bundle row.
|
||||
private static async Task ApplyNewerAsync(SecretsSession session, string name, string value)
|
||||
{
|
||||
StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text) with
|
||||
{
|
||||
UpdatedUtc = DateTimeOffset.UtcNow.AddDays(1),
|
||||
CreatedUtc = DateTimeOffset.UtcNow.AddDays(1),
|
||||
Revision = 7,
|
||||
};
|
||||
await session.Store.ApplyReplicatedAsync(row, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_prompts_for_path_and_reports_row_count()
|
||||
{
|
||||
LiteralMasterKeyProvider kek = NewKek(out _);
|
||||
SecretsSession session = await NewSessionAsync("a.db", kek);
|
||||
await SealAsync(session, "svc/one", "value-one");
|
||||
await SealAsync(session, "svc/two", "value-two");
|
||||
|
||||
string outPath = Path.Combine(_dir, "typed-bundle.json");
|
||||
|
||||
TestConsole console = NewConsole();
|
||||
console.Input.PushTextWithEnter(outPath); // path (overrides the offered default)
|
||||
console.Input.PushTextWithEnter("n"); // include tombstoned? no
|
||||
|
||||
await new BundleExportFlow().RunAsync(console, session, CancellationToken.None);
|
||||
|
||||
Assert.True(new BundleExportFlow().RequiresKek);
|
||||
Assert.True(File.Exists(outPath));
|
||||
Assert.Contains("2", console.Output, StringComparison.Ordinal);
|
||||
// The offered default filename carries the target name and the .json extension.
|
||||
Assert.Contains($"secrets-bundle-{session.Target.Name}", console.Output, StringComparison.Ordinal);
|
||||
Assert.Contains(".json", console.Output, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_reports_counts_and_applies_lww()
|
||||
{
|
||||
LiteralMasterKeyProvider kek = NewKek(out _);
|
||||
SecretsSession source = await NewSessionAsync("a.db", kek);
|
||||
await SealAsync(source, "svc/x", "bundle-older-value");
|
||||
|
||||
// Export the bundle from the source store (same KEK as the target).
|
||||
var exportConsole = NewConsole();
|
||||
exportConsole.Input.PushTextWithEnter(BundlePath);
|
||||
exportConsole.Input.PushTextWithEnter("n");
|
||||
await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);
|
||||
|
||||
// Target holds a newer conflicting row: LWW must keep it and skip the bundle row.
|
||||
SecretsSession target = await NewSessionAsync("b.db", kek);
|
||||
await ApplyNewerAsync(target, "svc/x", "local-newer-value");
|
||||
|
||||
TestConsole console = NewConsole();
|
||||
console.Input.PushTextWithEnter(BundlePath); // path (same KEK -> no source-key prompt)
|
||||
console.Input.PushTextWithEnter("n"); // prompt per conflict? no -> pure LWW
|
||||
|
||||
await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
|
||||
|
||||
Assert.Contains("Imported", console.Output, StringComparison.Ordinal); // report table rendered
|
||||
StoredSecret? after = await target.Store.GetAsync(new SecretName("svc/x"), CancellationToken.None);
|
||||
Assert.NotNull(after);
|
||||
Assert.Equal("local-newer-value", target.Cipher!.Decrypt(after!)); // LWW kept the newer local row
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_conflict_prompt_lets_operator_pick_per_row()
|
||||
{
|
||||
LiteralMasterKeyProvider kek = NewKek(out _);
|
||||
SecretsSession source = await NewSessionAsync("a.db", kek);
|
||||
await SealAsync(source, "svc/a", "bundle-a");
|
||||
await SealAsync(source, "svc/b", "bundle-b");
|
||||
|
||||
var exportConsole = NewConsole();
|
||||
exportConsole.Input.PushTextWithEnter(BundlePath);
|
||||
exportConsole.Input.PushTextWithEnter("n");
|
||||
await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);
|
||||
|
||||
// Both names already exist in the target (as newer rows) -> both are conflicts.
|
||||
SecretsSession target = await NewSessionAsync("b.db", kek);
|
||||
await ApplyNewerAsync(target, "svc/a", "local-a");
|
||||
await ApplyNewerAsync(target, "svc/b", "local-b");
|
||||
|
||||
TestConsole console = NewConsole();
|
||||
console.Input.PushTextWithEnter(BundlePath); // path
|
||||
console.Input.PushTextWithEnter("y"); // prompt per conflict? yes
|
||||
// Bundle entries arrive in name order: svc/a first, svc/b second.
|
||||
console.Input.PushTextWithEnter("n"); // svc/a: take the bundle row? no -> keep local
|
||||
console.Input.PushTextWithEnter("y"); // svc/b: take the bundle row? yes -> take bundle
|
||||
|
||||
await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
|
||||
|
||||
StoredSecret? a = await target.Store.GetAsync(new SecretName("svc/a"), CancellationToken.None);
|
||||
StoredSecret? b = await target.Store.GetAsync(new SecretName("svc/b"), CancellationToken.None);
|
||||
Assert.Equal("local-a", target.Cipher!.Decrypt(a!)); // kept local
|
||||
Assert.Equal("bundle-b", target.Cipher!.Decrypt(b!)); // took the bundle
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_foreign_kek_prompts_for_source_key_then_rewraps()
|
||||
{
|
||||
LiteralMasterKeyProvider kekA = NewKek(out string base64A);
|
||||
LiteralMasterKeyProvider kekB = NewKek(out _);
|
||||
Assert.NotEqual(kekA.KekId, kekB.KekId);
|
||||
|
||||
SecretsSession source = await NewSessionAsync("a.db", kekA);
|
||||
await SealAsync(source, "svc/cross", "cross-value");
|
||||
|
||||
var exportConsole = NewConsole();
|
||||
exportConsole.Input.PushTextWithEnter(BundlePath);
|
||||
exportConsole.Input.PushTextWithEnter("n");
|
||||
await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);
|
||||
|
||||
// Session on KEK-B: the flow must detect the foreign source KEK and prompt for KEK-A.
|
||||
SecretsSession target = await NewSessionAsync("b.db", kekB);
|
||||
|
||||
TestConsole console = NewConsole();
|
||||
console.Input.PushTextWithEnter(BundlePath); // path
|
||||
console.Input.PushKey(ConsoleKey.Enter); // source-key selection: first choice (paste base64)
|
||||
console.Input.PushTextWithEnter(base64A); // pasted KEK-A material (masked)
|
||||
console.Input.PushTextWithEnter("n"); // prompt per conflict? no (empty target anyway)
|
||||
|
||||
await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);
|
||||
|
||||
StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
|
||||
Assert.NotNull(dst);
|
||||
Assert.Equal(kekB.KekId, dst!.KekId); // re-wrapped under the session KEK
|
||||
Assert.Equal("cross-value", target.Cipher!.Decrypt(dst)); // and decrypts
|
||||
|
||||
Assert.DoesNotContain(base64A, console.Output, StringComparison.Ordinal); // pasted key never echoed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user