Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [0.6.1] — 2026-05-03

### Fixed
- **Markup injection in `accounts` command output** — provider names, account names, environments, summary `DisplayFields`, exception messages, and the `--provider` flag echoed in the "Unknown provider" error now flow through `Markup.Escape` before reaching `MarkupLine` / `Table.AddRow` / Spectre selection prompts. A credential whose name happened to contain `[` or `]` previously corrupted the `accounts list` table rendering, and exception messages with markup-looking content (common on `JsonException` / IO errors) garbled the error line in `CommandErrorReporter`.
- **`accounts delete <short-id>` crash** — `accounts delete abc` (or any non-GUID id shorter than 8 chars) previously threw `ArgumentOutOfRangeException` on the `accountId[..8]` slice in the confirm prompt before reaching "not found". A new `CommandFormatting.ShortId` helper safely abbreviates, and `DeleteCredentialAsync` / `SelectCredentialAsync` / `GetCredentialByIdAsync` now validate that `accountId` is a GUID before any filesystem call. Non-GUIDs resolve to deterministic `false` on the mutating APIs and `ArgumentException` on the strict `GetCredentialByIdAsync`. Closes a path-traversal/glob-injection vector where `*` or `..\..\foo` could have flowed into `Path.Combine` and `Directory.GetFiles` patterns.
- **Concurrent `accounts select` lost updates** — `selections.json` was read-modify-written without serialization, so two CLI invocations modifying different providers could lose one provider's update. Reads/writes now happen under a cross-process advisory lock backed by `selections.json.lock` (`FileShare.None` + `FileOptions.DeleteOnClose` + exponential backoff up to ~5s). Atomic rename was already preventing torn files; this closes the lost-update window. The Keychain and libsecret backends are unaffected — both OS stores serialise their own writes.
- **Unbalanced parenthesis in `accounts select` and `accounts delete` choice prompts** — drive-by fix while replacing the unsafe `[..8]` slices.

### Changed
- **`PackageOutputPath`** — was hard-coded as `C:\nuget-local\`, which on Linux produced surprising `src/NextIteration.SpectreConsole.Auth/C:/nuget-local/…` repo churn. Now repo-relative (`$(MSBuildThisFileDirectory)..\..\artifacts\packages`) and already gitignored.
- **`GeneratePackageOnBuild`** — now scoped to `Release` builds via MSBuild condition. `dotnet test` and Debug builds no longer pack, materially shortening the local test loop.

### Tests
- 18 new tests; suite now at 145.
- `CommandFormatting.ShortId` across full GUIDs, short strings, exact-8 boundary, null/empty.
- Non-GUID `accountId` resolves to deterministic not-found on file-backend `Delete`/`Select`; throws on `GetCredentialByIdAsync`.
- Concurrent `SelectCredentialAsync` on different providers both persist (regression test for the `selections.json` race).
- `SelectionsLock` uncontended/contended/post-release semantics.

---

## [0.6.0] — 2026-04-18

### Added
Expand Down Expand Up @@ -79,6 +100,7 @@ Consumers needed a way to read a specific stored credential's secret at runtime
- SourceLink, deterministic builds, embedded symbols, published symbol packages.
- `TreatWarningsAsErrors=true`, `AnalysisLevel=latest` — zero-warning public API.

[0.6.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v0.6.1
[0.5.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v0.5.0
[0.4.2]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v0.4.2
[0.4.1]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v0.4.1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings

if (!_collectorsByProvider.TryGetValue(settings.Provider, out var collector))
{
AnsiConsole.MarkupLine($"[red]Unknown provider: {settings.Provider}[/]");
AnsiConsole.MarkupLine($"[red]Unknown provider: {Markup.Escape(settings.Provider)}[/]");
return 1;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ internal static class CommandErrorReporter
/// </summary>
internal static void Report(Exception ex, string contextMessage, bool verbose)
{
// contextMessage is library-internal today, but escape defensively
// so a future caller passing user-derived text can't break the line.
// ex.Message is always external — JSON parse errors, IO errors, etc.
// commonly contain '[' / ']' which Spectre would otherwise interpret
// as malformed markup.
if (verbose)
{
AnsiConsole.MarkupLine($"[red]{contextMessage}[/]");
AnsiConsole.MarkupLine($"[red]{Markup.Escape(contextMessage)}[/]");
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
}
else
{
AnsiConsole.MarkupLine($"[red]{contextMessage}: {ex.Message}[/]");
AnsiConsole.MarkupLine($"[red]{Markup.Escape(contextMessage)}: {Markup.Escape(ex.Message)}[/]");
AnsiConsole.MarkupLine("[grey]Run with --verbose for more detail.[/]");
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace NextIteration.SpectreConsole.Auth.Commands
{
/// <summary>
/// Display helpers shared across the <c>accounts</c> commands. Centralised
/// so an unusually short or malformed account id (e.g. a tampered file on
/// disk, or a user-supplied id that didn't pass GUID parsing) doesn't
/// crash the command via an out-of-range slice.
/// </summary>
internal static class CommandFormatting
{
/// <summary>
/// Returns a display-friendly abbreviation of an account id. Library
/// account ids are GUIDs (36 chars), but on the user-supplied path
/// we may receive arbitrarily short strings before validation has
/// run — falling back to the full string keeps error messages
/// useful instead of throwing.
/// </summary>
internal static string ShortId(string? accountId)
{
if (string.IsNullOrEmpty(accountId)) return string.Empty;
return accountId.Length >= 8 ? accountId[..8] + "..." : accountId;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
}

var choices = allCredentials.Select(c =>
$"{c.AccountName} ({c.ProviderName} - {c.AccountId[..8]}...").ToArray();
$"{Markup.Escape(c.AccountName)} ({Markup.Escape(c.ProviderName)} - {CommandFormatting.ShortId(c.AccountId)})").ToArray();

var selectedChoice = await AnsiConsole.PromptAsync(
new SelectionPrompt<string>()
Expand All @@ -63,7 +63,7 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
}

// Confirm deletion
if (!settings.Force && !await AnsiConsole.ConfirmAsync($"Are you sure you want to delete credential '{accountId[..8]}...'?", cancellationToken: cancellationToken).ConfigureAwait(false))
if (!settings.Force && !await AnsiConsole.ConfirmAsync($"Are you sure you want to delete credential '{Markup.Escape(CommandFormatting.ShortId(accountId))}'?", cancellationToken: cancellationToken).ConfigureAwait(false))
{
AnsiConsole.MarkupLine("[yellow]Deletion cancelled.[/]");
return 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ private async Task DisplayCredentialsForProvider(string provider)

if (credentials.Count == 0)
{
AnsiConsole.MarkupLine($"[yellow]No credentials found for provider '{provider}'.[/]");
AnsiConsole.MarkupLine($"[yellow]No credentials found for provider '{Markup.Escape(provider)}'.[/]");
return;
}

Expand All @@ -94,7 +94,7 @@ private async Task DisplayCredentialsForProvider(string provider)
}
}

AnsiConsole.MarkupLine($"[bold]{provider}[/]");
AnsiConsole.MarkupLine($"[bold]{Markup.Escape(provider)}[/]");

var table = new Table();
_ = table.AddColumn("ID");
Expand All @@ -109,18 +109,21 @@ private async Task DisplayCredentialsForProvider(string provider)

foreach (var credential in credentials)
{
// Cells render via Spectre markup, so user/provider-controlled
// values must be escaped — an account name like "[red]X[/]"
// would otherwise be parsed as styling rather than text.
var row = new List<string>
{
credential.AccountId[..8] + "...",
credential.AccountName,
credential.Environment,
CommandFormatting.ShortId(credential.AccountId),
Markup.Escape(credential.AccountName),
Markup.Escape(credential.Environment),
};

foreach (var key in displayFieldKeys)
{
var value = credential.DisplayFields
.FirstOrDefault(kvp => string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase));
row.Add(value.Value ?? string.Empty);
row.Add(Markup.Escape(value.Value ?? string.Empty));
}

row.Add(credential.CreatedAt.ToString("yyyy-MM-dd HH:mm"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
}

var choices = allCredentials.Select(c =>
$"{c.AccountName} ({c.ProviderName} - {c.AccountId[..8]}... {(c.IsSelected ? "[green](selected)[/]" : "")}").ToArray();
$"{Markup.Escape(c.AccountName)} ({Markup.Escape(c.ProviderName)} - {CommandFormatting.ShortId(c.AccountId)}) {(c.IsSelected ? "[green](selected)[/]" : "")}").ToArray();

var selectedChoice = await AnsiConsole.PromptAsync(new SelectionPrompt<string>()
.Title("Select credential to [green]activate[/]:")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.Auth</PackageId>
<Version>0.6.0</Version>
<Version>0.6.1</Version>
<Authors>Stuart Meeks</Authors>
<Description>Credential storage, encryption, and Spectre.Console CLI commands for managing provider credentials in CLI tools.</Description>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageOutputPath>C:\nuget-local\</PackageOutputPath>
<GeneratePackageOnBuild Condition="'$(Configuration)' == 'Release'">true</GeneratePackageOnBuild>
<PackageOutputPath>$(MSBuildThisFileDirectory)..\..\artifacts\packages</PackageOutputPath>
<IncludeBuildOutput>true</IncludeBuildOutput>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class FileCredentialManager : ICredentialManager

private readonly string _credentialsDirectory;
private readonly string _selectionFile;
private readonly string _selectionLockFile;
private readonly ICredentialEncryption _encryption;
private readonly Dictionary<string, ICredentialSummaryProvider> _summaryProviders;

Expand All @@ -41,6 +42,7 @@ public FileCredentialManager(
_encryption = encryption;
_credentialsDirectory = credentialsDirectory;
_selectionFile = Path.Combine(_credentialsDirectory, "selections.json");
_selectionLockFile = Path.Combine(_credentialsDirectory, "selections.json.lock");
_summaryProviders = (summaryProviders ?? [])
.ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase);

Expand Down Expand Up @@ -144,6 +146,8 @@ await AtomicFile.WriteAllTextAsync(
/// <inheritdoc />
public async Task<bool> DeleteCredentialAsync(string accountId)
{
if (!IsValidAccountId(accountId)) return false;

var found = await FindCredentialByAccountIdAsync(accountId).ConfigureAwait(false);
if (found is null)
{
Expand All @@ -153,7 +157,9 @@ public async Task<bool> DeleteCredentialAsync(string accountId)
var (filePath, credential) = found.Value;
File.Delete(filePath);

// Remove the selection entry if it pointed at this credential.
// Hold the selections lock across load+modify+save so a concurrent
// select/delete on a different provider can't lose its update.
using var selectionsLock = await SelectionsLock.AcquireAsync(_selectionLockFile).ConfigureAwait(false);
var selections = await LoadSelectionsAsync().ConfigureAwait(false);
if (selections.TryGetValue(credential.ProviderName, out var selectedId) &&
selectedId.Equals(accountId, StringComparison.OrdinalIgnoreCase))
Expand All @@ -168,13 +174,16 @@ public async Task<bool> DeleteCredentialAsync(string accountId)
/// <inheritdoc />
public async Task<bool> SelectCredentialAsync(string accountId)
{
if (!IsValidAccountId(accountId)) return false;

var found = await FindCredentialByAccountIdAsync(accountId).ConfigureAwait(false);
if (found is null)
{
return false;
}

var credential = found.Value.Credential;
using var selectionsLock = await SelectionsLock.AcquireAsync(_selectionLockFile).ConfigureAwait(false);
var selections = await LoadSelectionsAsync().ConfigureAwait(false);
selections[credential.ProviderName] = accountId;
await SaveSelectionsAsync(selections).ConfigureAwait(false);
Expand All @@ -187,8 +196,11 @@ public async Task<bool> SelectCredentialAsync(string accountId)
ValidateProviderName(providerName);

var selections = await LoadSelectionsAsync().ConfigureAwait(false);
if (!selections.TryGetValue(providerName, out var selectedId))
if (!selections.TryGetValue(providerName, out var selectedId) || !IsValidAccountId(selectedId))
{
// A non-GUID id can only land here via tampered selections.json;
// treat it as "no selection" rather than letting a malformed
// string flow into Path.Combine.
return null;
}

Expand All @@ -199,7 +211,7 @@ public async Task<bool> SelectCredentialAsync(string accountId)
public async Task<string?> GetCredentialByIdAsync(string providerName, string accountId)
{
ValidateProviderName(providerName);
ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
ValidateAccountId(accountId);

return await ReadAndDecryptByIdAsync(providerName, accountId).ConfigureAwait(false);
}
Expand Down Expand Up @@ -314,6 +326,31 @@ private static void ValidateProviderName(string providerName)
}
}

/// <summary>
/// Library-generated account ids are always GUIDs. Caller-supplied
/// strings are constrained to that shape so they can't smuggle glob
/// metacharacters (<c>*</c>, <c>?</c>) or path-traversal segments
/// (<c>..</c>) into the file lookups.
/// </summary>
private static bool IsValidAccountId(string? accountId) =>
!string.IsNullOrWhiteSpace(accountId) && Guid.TryParse(accountId, out _);

/// <summary>
/// Throwing variant of <see cref="IsValidAccountId"/> used at API
/// boundaries where a malformed id is a programmer error rather than
/// a "not found" condition.
/// </summary>
private static void ValidateAccountId(string accountId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
if (!Guid.TryParse(accountId, out _))
{
throw new ArgumentException(
$"Account id '{accountId}' is not a valid GUID.",
nameof(accountId));
}
}

private void EnsureDirectoryExists() => CredentialsDirectory.Ensure(_credentialsDirectory);

private async Task<Dictionary<string, string>> LoadSelectionsAsync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ public Task<IEnumerable<CredentialSummary>> ListCredentialsAsync(string provider
/// <inheritdoc />
public Task<bool> DeleteCredentialAsync(string accountId)
{
if (!IsValidAccountId(accountId)) return Task.FromResult(false);

var match = FindItemByAccountId(accountId);
if (match is null)
return Task.FromResult(false);
Expand All @@ -133,6 +135,8 @@ public Task<bool> DeleteCredentialAsync(string accountId)
/// <inheritdoc />
public Task<bool> SelectCredentialAsync(string accountId)
{
if (!IsValidAccountId(accountId)) return Task.FromResult(false);

var match = FindItemByAccountId(accountId);
if (match is null)
return Task.FromResult(false);
Expand Down Expand Up @@ -160,7 +164,7 @@ public Task<bool> SelectCredentialAsync(string accountId)
public Task<string?> GetCredentialByIdAsync(string providerName, string accountId)
{
ValidateProviderName(providerName);
ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
ValidateAccountId(accountId);

return Task.FromResult(ReadItemDataById(providerName, accountId));
}
Expand Down Expand Up @@ -284,6 +288,20 @@ private static void ValidateProviderName(string providerName)
}
}

private static bool IsValidAccountId(string? accountId) =>
!string.IsNullOrWhiteSpace(accountId) && Guid.TryParse(accountId, out _);

private static void ValidateAccountId(string accountId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
if (!Guid.TryParse(accountId, out _))
{
throw new ArgumentException(
$"Account id '{accountId}' is not a valid GUID.",
nameof(accountId));
}
}

// =========================
// Keychain operations — each takes ownership of every CF object it
// creates and releases in finally.
Expand Down
Loading