diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cdbc71..ca5e36e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **Enabled `EnforceCodeStyleInBuild`** (NextIteration.Standards §1.2.1, now a `MUST`). The
+ canonical `.editorconfig`'s gated rules now fail the build instead of merely showing in
+ the IDE, so the house style is enforced. Bringing the code green under the flag was a
+ mechanical, behavior-preserving reformat — braces on all single-statement `if`s, block-
+ scoped namespaces (the interop layer and tests were file-scoped), and minor
+ expression-body/accessibility/collection-expression fixes — applied with `dotnet format`.
+ `IDE0005` is suppressed in the **test** project only: it requires `GenerateDocumentationFile`,
+ which §2.7 sets to `false` for tests, so gating it there would hard-error; it still gates
+ the shipping project. (§2.7 needs the matching amendment estate-wide.)
+
- **Adopted the revised canonical `.editorconfig`** (NextIteration.Standards §5.2). The new
file is a deliberate allow-list of gated style rules (no blanket
`dotnet_analyzer_diagnostic.severity`) and fixes the private-field naming rule that had
diff --git a/Directory.Build.props b/Directory.Build.props
index a392784..3c9715a 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -4,6 +4,7 @@
enable
en
latest
+ true
true
true
true
diff --git a/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs b/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs
index 8b4a75e..b748903 100644
--- a/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs
+++ b/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs
@@ -1,4 +1,5 @@
using NextIteration.SpectreConsole.Auth.Commands;
+
using Spectre.Console.Cli;
namespace NextIteration.SpectreConsole.Auth
diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/AccountsCommandSettings.cs b/src/NextIteration.SpectreConsole.Auth/Commands/AccountsCommandSettings.cs
index 36c4929..68b05e3 100644
--- a/src/NextIteration.SpectreConsole.Auth/Commands/AccountsCommandSettings.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Commands/AccountsCommandSettings.cs
@@ -1,6 +1,7 @@
-using Spectre.Console.Cli;
using System.ComponentModel;
+using Spectre.Console.Cli;
+
namespace NextIteration.SpectreConsole.Auth.Commands
{
///
diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/AddCredentialCommand.cs b/src/NextIteration.SpectreConsole.Auth/Commands/AddCredentialCommand.cs
index 70b4b9e..0470345 100644
--- a/src/NextIteration.SpectreConsole.Auth/Commands/AddCredentialCommand.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Commands/AddCredentialCommand.cs
@@ -1,7 +1,8 @@
-using Spectre.Console;
using System.ComponentModel;
using NextIteration.SpectreConsole.Auth.Persistence;
+
+using Spectre.Console;
using Spectre.Console.Cli;
namespace NextIteration.SpectreConsole.Auth.Commands
diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/CommandFormatting.cs b/src/NextIteration.SpectreConsole.Auth/Commands/CommandFormatting.cs
index 006f263..965c20f 100644
--- a/src/NextIteration.SpectreConsole.Auth/Commands/CommandFormatting.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Commands/CommandFormatting.cs
@@ -17,7 +17,11 @@ internal static class CommandFormatting
///
internal static string ShortId(string? accountId)
{
- if (string.IsNullOrEmpty(accountId)) return string.Empty;
+ if (string.IsNullOrEmpty(accountId))
+ {
+ return string.Empty;
+ }
+
return accountId.Length >= 8 ? accountId[..8] + "..." : accountId;
}
}
diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/DeleteCredentialCommand.cs b/src/NextIteration.SpectreConsole.Auth/Commands/DeleteCredentialCommand.cs
index 9540384..d4bec62 100644
--- a/src/NextIteration.SpectreConsole.Auth/Commands/DeleteCredentialCommand.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Commands/DeleteCredentialCommand.cs
@@ -1,7 +1,8 @@
-using Spectre.Console;
using System.ComponentModel;
using NextIteration.SpectreConsole.Auth.Persistence;
+
+using Spectre.Console;
using Spectre.Console.Cli;
namespace NextIteration.SpectreConsole.Auth.Commands
diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/ExportCredentialsCommand.cs b/src/NextIteration.SpectreConsole.Auth/Commands/ExportCredentialsCommand.cs
index bb72592..a2e627b 100644
--- a/src/NextIteration.SpectreConsole.Auth/Commands/ExportCredentialsCommand.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Commands/ExportCredentialsCommand.cs
@@ -1,8 +1,9 @@
-using Spectre.Console;
using System.ComponentModel;
using NextIteration.SpectreConsole.Auth.Persistence;
using NextIteration.SpectreConsole.Auth.Portability;
+
+using Spectre.Console;
using Spectre.Console.Cli;
namespace NextIteration.SpectreConsole.Auth.Commands
diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/ImportCredentialsCommand.cs b/src/NextIteration.SpectreConsole.Auth/Commands/ImportCredentialsCommand.cs
index f7a7ed6..350f4a6 100644
--- a/src/NextIteration.SpectreConsole.Auth/Commands/ImportCredentialsCommand.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Commands/ImportCredentialsCommand.cs
@@ -1,8 +1,9 @@
-using Spectre.Console;
using System.ComponentModel;
using NextIteration.SpectreConsole.Auth.Persistence;
using NextIteration.SpectreConsole.Auth.Portability;
+
+using Spectre.Console;
using Spectre.Console.Cli;
namespace NextIteration.SpectreConsole.Auth.Commands
diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/ListCredentialsCommand.cs b/src/NextIteration.SpectreConsole.Auth/Commands/ListCredentialsCommand.cs
index 730caf0..c061a2b 100644
--- a/src/NextIteration.SpectreConsole.Auth/Commands/ListCredentialsCommand.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Commands/ListCredentialsCommand.cs
@@ -1,7 +1,8 @@
-using Spectre.Console;
using System.ComponentModel;
using NextIteration.SpectreConsole.Auth.Persistence;
+
+using Spectre.Console;
using Spectre.Console.Cli;
namespace NextIteration.SpectreConsole.Auth.Commands
diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/SelectCredentialCommand.cs b/src/NextIteration.SpectreConsole.Auth/Commands/SelectCredentialCommand.cs
index e8d965e..f6fa7cb 100644
--- a/src/NextIteration.SpectreConsole.Auth/Commands/SelectCredentialCommand.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Commands/SelectCredentialCommand.cs
@@ -1,7 +1,8 @@
-using Spectre.Console;
using System.ComponentModel;
using NextIteration.SpectreConsole.Auth.Persistence;
+
+using Spectre.Console;
using Spectre.Console.Cli;
namespace NextIteration.SpectreConsole.Auth.Commands
diff --git a/src/NextIteration.SpectreConsole.Auth/Credentials/ICredential.cs b/src/NextIteration.SpectreConsole.Auth/Credentials/ICredential.cs
index 0bb1e35..5d542b0 100644
--- a/src/NextIteration.SpectreConsole.Auth/Credentials/ICredential.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Credentials/ICredential.cs
@@ -13,19 +13,19 @@ public interface ICredential
/// Must be unique across providers and stable across versions — it
/// is embedded in the filename of each stored credential.
///
- public abstract static string ProviderName { get; }
+ abstract static string ProviderName { get; }
///
/// List of environment names the provider accepts (for example
/// Production, Staging). Used to populate the
/// environment-selection prompt during accounts add.
///
- public abstract static List SupportedEnvironments { get; }
+ abstract static List SupportedEnvironments { get; }
///
/// The environment this particular credential instance targets.
/// Must be one of the values returned by .
///
- public abstract string Environment { get; }
+ abstract string Environment { get; }
}
}
diff --git a/src/NextIteration.SpectreConsole.Auth/Encryption/CredentialEncryptionFactory.cs b/src/NextIteration.SpectreConsole.Auth/Encryption/CredentialEncryptionFactory.cs
index 14d25f9..c90726f 100644
--- a/src/NextIteration.SpectreConsole.Auth/Encryption/CredentialEncryptionFactory.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Encryption/CredentialEncryptionFactory.cs
@@ -19,10 +19,7 @@ public static class CredentialEncryptionFactory
/// — see its remarks for
/// the security implications of supplying it.
///
- public static ICredentialEncryption Create(string credentialsDirectory, byte[]? additionalEntropy = null)
- {
- return new LocalFileCredentialEncryption(credentialsDirectory, additionalEntropy);
- }
+ public static ICredentialEncryption Create(string credentialsDirectory, byte[]? additionalEntropy = null) => new LocalFileCredentialEncryption(credentialsDirectory, additionalEntropy);
///
/// Creates the file-based, cross-platform encryption implementation explicitly.
@@ -33,10 +30,7 @@ public static ICredentialEncryption Create(string credentialsDirectory, byte[]?
/// — see its remarks for
/// the security implications of supplying it.
///
- public static ICredentialEncryption CreateLocalFile(string credentialsDirectory, byte[]? additionalEntropy = null)
- {
- return new LocalFileCredentialEncryption(credentialsDirectory, additionalEntropy);
- }
+ public static ICredentialEncryption CreateLocalFile(string credentialsDirectory, byte[]? additionalEntropy = null) => new LocalFileCredentialEncryption(credentialsDirectory, additionalEntropy);
///
/// Creates a Windows DPAPI encryption implementation. Windows only —
@@ -46,9 +40,6 @@ public static ICredentialEncryption CreateLocalFile(string credentialsDirectory,
///
/// Not running on Windows.
[SupportedOSPlatform("windows")]
- public static ICredentialEncryption CreateDpapi()
- {
- return new DpapiCredentialEncryption();
- }
+ public static ICredentialEncryption CreateDpapi() => new DpapiCredentialEncryption();
}
}
diff --git a/src/NextIteration.SpectreConsole.Auth/Encryption/DpapiCredentialEncryption.cs b/src/NextIteration.SpectreConsole.Auth/Encryption/DpapiCredentialEncryption.cs
index 7cc6d35..49cdce9 100644
--- a/src/NextIteration.SpectreConsole.Auth/Encryption/DpapiCredentialEncryption.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Encryption/DpapiCredentialEncryption.cs
@@ -28,7 +28,9 @@ public DpapiCredentialEncryption()
public Task EncryptAsync(string plainText)
{
if (string.IsNullOrEmpty(plainText))
+ {
return Task.FromResult(string.Empty);
+ }
try
{
@@ -50,7 +52,9 @@ public Task EncryptAsync(string plainText)
public Task DecryptAsync(string encryptedText)
{
if (string.IsNullOrEmpty(encryptedText))
+ {
return Task.FromResult(string.Empty);
+ }
try
{
diff --git a/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs b/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs
index 35968a3..b4b96a8 100644
--- a/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs
@@ -1,7 +1,8 @@
-using NextIteration.SpectreConsole.Auth.Persistence;
using System.Security.Cryptography;
using System.Text;
+using NextIteration.SpectreConsole.Auth.Persistence;
+
namespace NextIteration.SpectreConsole.Auth.Encryption
{
///
@@ -134,7 +135,9 @@ public LocalFileCredentialEncryption(string credentialsDirectory, byte[]? additi
public async Task EncryptAsync(string plainText)
{
if (string.IsNullOrEmpty(plainText))
+ {
return string.Empty;
+ }
try
{
@@ -168,7 +171,9 @@ public async Task EncryptAsync(string plainText)
public async Task DecryptAsync(string encryptedText)
{
if (string.IsNullOrEmpty(encryptedText))
+ {
return string.Empty;
+ }
byte[] input;
try
@@ -338,7 +343,9 @@ private static byte[] EncryptWithGcm(byte[] key, byte[] plaintext)
private static byte[] DecryptWithGcm(byte[] key, byte[] input)
{
if (input.Length < NonceSize + TagSize)
+ {
throw new InvalidOperationException("Encrypted payload is shorter than the AES-GCM header.");
+ }
var nonce = new byte[NonceSize];
var tag = new byte[TagSize];
diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/AtomicFile.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/AtomicFile.cs
index abe4b1f..e6bfa08 100644
--- a/src/NextIteration.SpectreConsole.Auth/Persistence/AtomicFile.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Persistence/AtomicFile.cs
@@ -105,15 +105,16 @@ private static async Task ReplaceAtomicallyAsync(string tempPath, string path)
}
}
- private static string BuildTempPath(string finalPath) =>
- // Unique per call to avoid collisions between concurrent writers,
- // who would otherwise both want the same `{path}.tmp` name.
- $"{finalPath}.{Guid.NewGuid():N}.tmp";
+ // Unique per call to avoid collisions between concurrent writers, who
+ // would otherwise both want the same `{path}.tmp` name.
+ private static string BuildTempPath(string finalPath) => $"{finalPath}.{Guid.NewGuid():N}.tmp";
private static void ApplyUnixModeIfRequested(string path, UnixFileMode? unixMode)
{
if (unixMode is null || OperatingSystem.IsWindows())
+ {
return;
+ }
File.SetUnixFileMode(path, unixMode.Value);
}
@@ -123,7 +124,9 @@ private static void TryDelete(string path)
try
{
if (File.Exists(path))
+ {
File.Delete(path);
+ }
}
catch
{
diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/CredentialsDirectory.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/CredentialsDirectory.cs
index 27f42a5..ec180e2 100644
--- a/src/NextIteration.SpectreConsole.Auth/Persistence/CredentialsDirectory.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Persistence/CredentialsDirectory.cs
@@ -25,7 +25,9 @@ internal static class CredentialsDirectory
internal static void Ensure(string path)
{
if (Directory.Exists(path))
+ {
return;
+ }
if (OperatingSystem.IsWindows())
{
diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs
index 9f8d5d2..751ba67 100644
--- a/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs
@@ -146,7 +146,10 @@ await AtomicFile.WriteAllTextAsync(
///
public async Task DeleteCredentialAsync(string accountId)
{
- if (!IsValidAccountId(accountId)) return false;
+ if (!IsValidAccountId(accountId))
+ {
+ return false;
+ }
var found = await FindCredentialByAccountIdAsync(accountId).ConfigureAwait(false);
if (found is null)
@@ -174,7 +177,10 @@ public async Task DeleteCredentialAsync(string accountId)
///
public async Task SelectCredentialAsync(string accountId)
{
- if (!IsValidAccountId(accountId)) return false;
+ if (!IsValidAccountId(accountId))
+ {
+ return false;
+ }
var found = await FindCredentialByAccountIdAsync(accountId).ConfigureAwait(false);
if (found is null)
diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs
index 9d73e8c..51450a2 100644
--- a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs
@@ -1,4 +1,3 @@
-using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
@@ -6,722 +5,796 @@
using static NextIteration.SpectreConsole.Auth.Persistence.Keychain.KeychainInterop;
-namespace NextIteration.SpectreConsole.Auth.Persistence.Keychain;
-
-///
-/// implementation backed by the macOS
-/// Keychain. Each stored credential becomes a generic-password item whose
-/// data blob carries the JSON payload and whose attributes carry the
-/// human-readable metadata (account name, environment, created-at).
-///
-///
-///
-/// This backend is marked experimental. P/Invoke against
-/// Security.framework is gnarly and the implementation has been exercised
-/// against a narrow set of macOS releases. Validate in your own environment
-/// before depending on it.
-///
-///
-/// The consumer-supplied appIdentifier is prefixed onto every
-/// kSecAttrService value so items from different CLIs don't collide
-/// in the same login keychain. Use a reverse-DNS style string like
-/// com.mycompany.my-cli.
-///
-///
-[SupportedOSPlatform("macos")]
-public sealed class KeychainCredentialManager : ICredentialManager
+namespace NextIteration.SpectreConsole.Auth.Persistence.Keychain
{
- // Service name is "{appIdentifier}.{providerName}" so we can enumerate
- // all credentials for a provider by querying that service. Selection
- // records use the special service "{appIdentifier}.__selections__".
- private const string SelectionsServiceSuffix = ".__selections__";
-
- private readonly string _appIdentifier;
- private readonly Dictionary _summaryProviders;
-
///
- /// Constructs the manager. isolates
- /// this CLI's keychain items from those of other apps on the same login
- /// keychain (e.g. com.mycompany.my-cli).
+ /// implementation backed by the macOS
+ /// Keychain. Each stored credential becomes a generic-password item whose
+ /// data blob carries the JSON payload and whose attributes carry the
+ /// human-readable metadata (account name, environment, created-at).
///
- public KeychainCredentialManager(
- string appIdentifier,
- IEnumerable? summaryProviders = null)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(appIdentifier);
- if (!OperatingSystem.IsMacOS())
+ ///
+ ///
+ /// This backend is marked experimental. P/Invoke against
+ /// Security.framework is gnarly and the implementation has been exercised
+ /// against a narrow set of macOS releases. Validate in your own environment
+ /// before depending on it.
+ ///
+ ///
+ /// The consumer-supplied appIdentifier is prefixed onto every
+ /// kSecAttrService value so items from different CLIs don't collide
+ /// in the same login keychain. Use a reverse-DNS style string like
+ /// com.mycompany.my-cli.
+ ///
+ ///
+ [SupportedOSPlatform("macos")]
+ public sealed class KeychainCredentialManager : ICredentialManager
+ {
+ // Service name is "{appIdentifier}.{providerName}" so we can enumerate
+ // all credentials for a provider by querying that service. Selection
+ // records use the special service "{appIdentifier}.__selections__".
+ private const string SelectionsServiceSuffix = ".__selections__";
+
+ private readonly string _appIdentifier;
+ private readonly Dictionary _summaryProviders;
+
+ ///
+ /// Constructs the manager. isolates
+ /// this CLI's keychain items from those of other apps on the same login
+ /// keychain (e.g. com.mycompany.my-cli).
+ ///
+ public KeychainCredentialManager(
+ string appIdentifier,
+ IEnumerable? summaryProviders = null)
{
- throw new PlatformNotSupportedException("KeychainCredentialManager is only available on macOS.");
- }
-
- _appIdentifier = appIdentifier;
- _summaryProviders = (summaryProviders ?? [])
- .ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase);
- }
+ ArgumentException.ThrowIfNullOrWhiteSpace(appIdentifier);
+ if (!OperatingSystem.IsMacOS())
+ {
+ throw new PlatformNotSupportedException("KeychainCredentialManager is only available on macOS.");
+ }
- ///
- public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData)
- {
- ValidateProviderName(providerName);
- var accountId = Guid.NewGuid().ToString();
+ _appIdentifier = appIdentifier;
+ _summaryProviders = (summaryProviders ?? [])
+ .ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase);
+ }
- var attrs = new KeychainItem
+ ///
+ public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData)
{
- Service = ServiceFor(providerName),
- Account = accountId,
- Label = accountName,
- Description = environment,
- Data = Encoding.UTF8.GetBytes(credentialData),
- };
-
- AddItem(attrs);
- return Task.FromResult(accountId);
- }
-
- ///
- public Task> ListCredentialsAsync(string providerName)
- {
- ValidateProviderName(providerName);
- var service = ServiceFor(providerName);
- _summaryProviders.TryGetValue(providerName, out var summaryProvider);
-
- var selectedId = ReadSelection(providerName);
- var items = QueryItems(service, includeData: summaryProvider is not null);
-
- var result = items
- .Select(i => new CredentialSummary
- {
- AccountId = i.Account ?? string.Empty,
- AccountName = i.Label ?? string.Empty,
- ProviderName = providerName,
- Environment = i.Description ?? string.Empty,
- CreatedAt = i.CreatedAt ?? DateTime.MinValue,
- IsSelected = selectedId is not null && string.Equals(selectedId, i.Account, StringComparison.OrdinalIgnoreCase),
- DisplayFields = summaryProvider is not null && i.Data is not null
- ? summaryProvider.GetDisplayFields(Encoding.UTF8.GetString(i.Data))
- : [],
- })
- .OrderBy(c => c.AccountName)
- .ToList();
-
- return Task.FromResult>(result);
- }
+ ValidateProviderName(providerName);
+ var accountId = Guid.NewGuid().ToString();
- ///
- public Task DeleteCredentialAsync(string accountId)
- {
- if (!IsValidAccountId(accountId)) return Task.FromResult(false);
+ var attrs = new KeychainItem
+ {
+ Service = ServiceFor(providerName),
+ Account = accountId,
+ Label = accountName,
+ Description = environment,
+ Data = Encoding.UTF8.GetBytes(credentialData),
+ };
- var match = FindItemByAccountId(accountId);
- if (match is null)
- return Task.FromResult(false);
+ AddItem(attrs);
+ return Task.FromResult(accountId);
+ }
- DeleteItem(match.Value.Service, match.Value.Account!);
+ ///
+ public Task> ListCredentialsAsync(string providerName)
+ {
+ ValidateProviderName(providerName);
+ var service = ServiceFor(providerName);
+ _summaryProviders.TryGetValue(providerName, out var summaryProvider);
+
+ var selectedId = ReadSelection(providerName);
+ var items = QueryItems(service, includeData: summaryProvider is not null);
+
+ var result = items
+ .Select(i => new CredentialSummary
+ {
+ AccountId = i.Account ?? string.Empty,
+ AccountName = i.Label ?? string.Empty,
+ ProviderName = providerName,
+ Environment = i.Description ?? string.Empty,
+ CreatedAt = i.CreatedAt ?? DateTime.MinValue,
+ IsSelected = selectedId is not null && string.Equals(selectedId, i.Account, StringComparison.OrdinalIgnoreCase),
+ DisplayFields = summaryProvider is not null && i.Data is not null
+ ? summaryProvider.GetDisplayFields(Encoding.UTF8.GetString(i.Data))
+ : [],
+ })
+ .OrderBy(c => c.AccountName)
+ .ToList();
+
+ return Task.FromResult>(result);
+ }
- // Clear the selection record if it pointed at this credential.
- var providerName = ProviderNameFromService(match.Value.Service);
- if (providerName is not null)
+ ///
+ public Task DeleteCredentialAsync(string accountId)
{
- var selected = ReadSelection(providerName);
- if (string.Equals(selected, accountId, StringComparison.OrdinalIgnoreCase))
+ if (!IsValidAccountId(accountId))
{
- DeleteSelection(providerName);
+ return Task.FromResult(false);
}
- }
-
- return Task.FromResult(true);
- }
- ///
- public Task SelectCredentialAsync(string accountId)
- {
- if (!IsValidAccountId(accountId)) return Task.FromResult(false);
+ var match = FindItemByAccountId(accountId);
+ if (match is null)
+ {
+ return Task.FromResult(false);
+ }
- var match = FindItemByAccountId(accountId);
- if (match is null)
- return Task.FromResult(false);
+ DeleteItem(match.Value.Service, match.Value.Account!);
- var providerName = ProviderNameFromService(match.Value.Service);
- if (providerName is null)
- return Task.FromResult(false);
+ // Clear the selection record if it pointed at this credential.
+ var providerName = ProviderNameFromService(match.Value.Service);
+ if (providerName is not null)
+ {
+ var selected = ReadSelection(providerName);
+ if (string.Equals(selected, accountId, StringComparison.OrdinalIgnoreCase))
+ {
+ DeleteSelection(providerName);
+ }
+ }
- WriteSelection(providerName, accountId);
- return Task.FromResult(true);
- }
+ return Task.FromResult(true);
+ }
- ///
- public Task GetSelectedCredentialAsync(string providerName)
- {
- ValidateProviderName(providerName);
- var selectedId = ReadSelection(providerName);
- if (selectedId is null)
- return Task.FromResult(null);
+ ///
+ public Task SelectCredentialAsync(string accountId)
+ {
+ if (!IsValidAccountId(accountId))
+ {
+ return Task.FromResult(false);
+ }
- return Task.FromResult(ReadItemDataById(providerName, selectedId));
- }
+ var match = FindItemByAccountId(accountId);
+ if (match is null)
+ {
+ return Task.FromResult(false);
+ }
- ///
- public Task GetCredentialByIdAsync(string providerName, string accountId)
- {
- ValidateProviderName(providerName);
- ValidateAccountId(accountId);
+ var providerName = ProviderNameFromService(match.Value.Service);
+ if (providerName is null)
+ {
+ return Task.FromResult(false);
+ }
- return Task.FromResult(ReadItemDataById(providerName, accountId));
- }
+ WriteSelection(providerName, accountId);
+ return Task.FromResult(true);
+ }
- ///
- /// Loads a generic-password Keychain item's kSecValueData for
- /// the given provider and account id. Returns
- /// when the item doesn't exist or has no payload. Shared by
- /// and
- /// — neither touches the
- /// selection record.
- ///
- private string? ReadItemDataById(string providerName, string accountId)
- {
- var service = ServiceFor(providerName);
- var item = QuerySingleItem(service, accountId, includeData: true);
- if (item is null || item.Data is null) return null;
- return Encoding.UTF8.GetString(item.Data);
- }
+ ///
+ public Task GetSelectedCredentialAsync(string providerName)
+ {
+ ValidateProviderName(providerName);
+ var selectedId = ReadSelection(providerName);
+ if (selectedId is null)
+ {
+ return Task.FromResult(null);
+ }
- ///
- public Task> GetProviderNamesAsync()
- {
- // Query every generic-password item owned by this app and distinct
- // the provider portion out of the service string.
- var items = QueryAllItemsForApp(includeData: false);
- var providerPrefix = _appIdentifier + ".";
- var names = items
- .Select(i => i.Service)
- .Where(s => s.StartsWith(providerPrefix, StringComparison.Ordinal) && !s.EndsWith(SelectionsServiceSuffix, StringComparison.Ordinal))
- .Select(s => s[providerPrefix.Length..])
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .OrderBy(s => s, StringComparer.Ordinal)
- .ToList();
-
- return Task.FromResult>(names);
- }
+ return Task.FromResult(ReadItemDataById(providerName, selectedId));
+ }
- ///
- public Task> ExportCredentialsAsync()
- {
- var items = QueryAllItemsForApp(includeData: true);
- var selectionCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ ///
+ public Task GetCredentialByIdAsync(string providerName, string accountId)
+ {
+ ValidateProviderName(providerName);
+ ValidateAccountId(accountId);
- // Only real credential items are exported: skip the selection records,
- // and any item whose service/account doesn't resolve to a credential.
- var credentialItems = items.Where(i =>
- !i.Service.EndsWith(SelectionsServiceSuffix, StringComparison.Ordinal)
- && i.Account is not null
- && ProviderNameFromService(i.Service) is not null);
+ return Task.FromResult(ReadItemDataById(providerName, accountId));
+ }
- var exports = new List();
- foreach (var item in credentialItems)
+ ///
+ /// Loads a generic-password Keychain item's kSecValueData for
+ /// the given provider and account id. Returns
+ /// when the item doesn't exist or has no payload. Shared by
+ /// and
+ /// — neither touches the
+ /// selection record.
+ ///
+ private string? ReadItemDataById(string providerName, string accountId)
{
- var providerName = ProviderNameFromService(item.Service)!;
-
- if (!selectionCache.TryGetValue(providerName, out var selectedId))
+ var service = ServiceFor(providerName);
+ var item = QuerySingleItem(service, accountId, includeData: true);
+ if (item is null || item.Data is null)
{
- selectedId = ReadSelection(providerName);
- selectionCache[providerName] = selectedId;
+ return null;
}
- exports.Add(new CredentialExport
- {
- AccountId = item.Account!, // non-null: guaranteed by the Where filter above
- AccountName = item.Label ?? string.Empty,
- ProviderName = providerName,
- Environment = item.Description ?? string.Empty,
- CredentialData = item.Data is not null ? Encoding.UTF8.GetString(item.Data) : string.Empty,
- CreatedAt = item.CreatedAt ?? DateTime.MinValue,
- IsSelected = selectedId is not null && string.Equals(selectedId, item.Account, StringComparison.OrdinalIgnoreCase),
- });
+ return Encoding.UTF8.GetString(item.Data);
}
- return Task.FromResult>(exports);
- }
-
- ///
- public Task RestoreCredentialAsync(CredentialExport credential)
- {
- ArgumentNullException.ThrowIfNull(credential);
- ValidateProviderName(credential.ProviderName);
- ValidateAccountId(credential.AccountId);
-
- var service = ServiceFor(credential.ProviderName);
-
- // Replace any existing item with the same service + account id so a
- // re-import is idempotent. Keychain has no atomic upsert, so delete
- // then add — the creation date is reassigned by macOS regardless, so
- // CreatedAt is intentionally not carried across.
- var existing = QuerySingleItem(service, credential.AccountId, includeData: false);
- if (existing is not null)
+ ///
+ public Task> GetProviderNamesAsync()
{
- DeleteItem(service, credential.AccountId);
+ // Query every generic-password item owned by this app and distinct
+ // the provider portion out of the service string.
+ var items = QueryAllItemsForApp(includeData: false);
+ var providerPrefix = _appIdentifier + ".";
+ var names = items
+ .Select(i => i.Service)
+ .Where(s => s.StartsWith(providerPrefix, StringComparison.Ordinal) && !s.EndsWith(SelectionsServiceSuffix, StringComparison.Ordinal))
+ .Select(s => s[providerPrefix.Length..])
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(s => s, StringComparer.Ordinal)
+ .ToList();
+
+ return Task.FromResult>(names);
}
- AddItem(new KeychainItem
+ ///
+ public Task> ExportCredentialsAsync()
{
- Service = service,
- Account = credential.AccountId,
- Label = credential.AccountName,
- Description = credential.Environment,
- Data = Encoding.UTF8.GetBytes(credential.CredentialData),
- });
+ var items = QueryAllItemsForApp(includeData: true);
+ var selectionCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Only real credential items are exported: skip the selection records,
+ // and any item whose service/account doesn't resolve to a credential.
+ var credentialItems = items.Where(i =>
+ !i.Service.EndsWith(SelectionsServiceSuffix, StringComparison.Ordinal)
+ && i.Account is not null
+ && ProviderNameFromService(i.Service) is not null);
+
+ var exports = new List();
+ foreach (var item in credentialItems)
+ {
+ var providerName = ProviderNameFromService(item.Service)!;
+
+ if (!selectionCache.TryGetValue(providerName, out var selectedId))
+ {
+ selectedId = ReadSelection(providerName);
+ selectionCache[providerName] = selectedId;
+ }
+
+ exports.Add(new CredentialExport
+ {
+ AccountId = item.Account!, // non-null: guaranteed by the Where filter above
+ AccountName = item.Label ?? string.Empty,
+ ProviderName = providerName,
+ Environment = item.Description ?? string.Empty,
+ CredentialData = item.Data is not null ? Encoding.UTF8.GetString(item.Data) : string.Empty,
+ CreatedAt = item.CreatedAt ?? DateTime.MinValue,
+ IsSelected = selectedId is not null && string.Equals(selectedId, item.Account, StringComparison.OrdinalIgnoreCase),
+ });
+ }
- if (credential.IsSelected)
- {
- WriteSelection(credential.ProviderName, credential.AccountId);
+ return Task.FromResult>(exports);
}
- return Task.CompletedTask;
- }
+ ///
+ public Task RestoreCredentialAsync(CredentialExport credential)
+ {
+ ArgumentNullException.ThrowIfNull(credential);
+ ValidateProviderName(credential.ProviderName);
+ ValidateAccountId(credential.AccountId);
+
+ var service = ServiceFor(credential.ProviderName);
+
+ // Replace any existing item with the same service + account id so a
+ // re-import is idempotent. Keychain has no atomic upsert, so delete
+ // then add — the creation date is reassigned by macOS regardless, so
+ // CreatedAt is intentionally not carried across.
+ var existing = QuerySingleItem(service, credential.AccountId, includeData: false);
+ if (existing is not null)
+ {
+ DeleteItem(service, credential.AccountId);
+ }
- // =========================
- // Internal helpers
- // =========================
+ AddItem(new KeychainItem
+ {
+ Service = service,
+ Account = credential.AccountId,
+ Label = credential.AccountName,
+ Description = credential.Environment,
+ Data = Encoding.UTF8.GetBytes(credential.CredentialData),
+ });
+
+ if (credential.IsSelected)
+ {
+ WriteSelection(credential.ProviderName, credential.AccountId);
+ }
- private string ServiceFor(string providerName) => $"{_appIdentifier}.{providerName}";
+ return Task.CompletedTask;
+ }
- private string SelectionsService => $"{_appIdentifier}{SelectionsServiceSuffix}";
+ // =========================
+ // Internal helpers
+ // =========================
- private string? ProviderNameFromService(string service)
- {
- var prefix = _appIdentifier + ".";
- if (!service.StartsWith(prefix, StringComparison.Ordinal)) return null;
- var candidate = service[prefix.Length..];
- return candidate == SelectionsServiceSuffix.TrimStart('.') ? null : candidate;
- }
+ private string ServiceFor(string providerName) => $"{_appIdentifier}.{providerName}";
- private string? ReadSelection(string providerName)
- {
- var item = QuerySingleItem(SelectionsService, providerName, includeData: true);
- if (item?.Data is null) return null;
- return Encoding.UTF8.GetString(item.Data);
- }
+ private string SelectionsService => $"{_appIdentifier}{SelectionsServiceSuffix}";
- private void WriteSelection(string providerName, string accountId)
- {
- var existing = QuerySingleItem(SelectionsService, providerName, includeData: false);
- var bytes = Encoding.UTF8.GetBytes(accountId);
- if (existing is null)
+ private string? ProviderNameFromService(string service)
{
- AddItem(new KeychainItem
+ var prefix = _appIdentifier + ".";
+ if (!service.StartsWith(prefix, StringComparison.Ordinal))
{
- Service = SelectionsService,
- Account = providerName,
- Label = $"{_appIdentifier} active credential ({providerName})",
- Description = string.Empty,
- Data = bytes,
- });
- }
- else
- {
- UpdateItemData(SelectionsService, providerName, bytes);
- }
- }
+ return null;
+ }
- private void DeleteSelection(string providerName)
- {
- DeleteItem(SelectionsService, providerName);
- }
+ var candidate = service[prefix.Length..];
+ return candidate == SelectionsServiceSuffix.TrimStart('.') ? null : candidate;
+ }
- private (string Service, string? Account)? FindItemByAccountId(string accountId)
- {
- // Enumerate all app-owned items; pick the one whose account matches.
- // Keychain doesn't index on account alone across services, so this
- // is a linear scan — acceptable because credential counts are tiny.
- var match = QueryAllItemsForApp(includeData: false)
- .FirstOrDefault(item =>
- string.Equals(item.Account, accountId, StringComparison.OrdinalIgnoreCase)
- && !item.Service.EndsWith(SelectionsServiceSuffix, StringComparison.Ordinal));
-
- return match is null ? null : (match.Service, match.Account);
- }
+ private string? ReadSelection(string providerName)
+ {
+ var item = QuerySingleItem(SelectionsService, providerName, includeData: true);
+ if (item?.Data is null)
+ {
+ return null;
+ }
- // =========================
- // Provider-name validation — mirrors FileCredentialManager rules so the
- // two backends accept the same set of names.
- // =========================
+ return Encoding.UTF8.GetString(item.Data);
+ }
- private static void ValidateProviderName(string providerName)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(providerName);
- if (providerName.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '.' && c != '_' && c != '-'))
+ private void WriteSelection(string providerName, string accountId)
{
- throw new ArgumentException(
- $"Provider name '{providerName}' contains invalid characters. Allowed: ASCII letters, digits, '.', '_', '-'.",
- nameof(providerName));
+ var existing = QuerySingleItem(SelectionsService, providerName, includeData: false);
+ var bytes = Encoding.UTF8.GetBytes(accountId);
+ if (existing is null)
+ {
+ AddItem(new KeychainItem
+ {
+ Service = SelectionsService,
+ Account = providerName,
+ Label = $"{_appIdentifier} active credential ({providerName})",
+ Description = string.Empty,
+ Data = bytes,
+ });
+ }
+ else
+ {
+ UpdateItemData(SelectionsService, providerName, bytes);
+ }
}
- }
- private static bool IsValidAccountId(string? accountId) =>
- !string.IsNullOrWhiteSpace(accountId) && Guid.TryParse(accountId, out _);
+ private void DeleteSelection(string providerName) => DeleteItem(SelectionsService, providerName);
- private static void ValidateAccountId(string accountId)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
- if (!Guid.TryParse(accountId, out _))
+ private (string Service, string? Account)? FindItemByAccountId(string accountId)
{
- throw new ArgumentException(
- $"Account id '{accountId}' is not a valid GUID.",
- nameof(accountId));
+ // Enumerate all app-owned items; pick the one whose account matches.
+ // Keychain doesn't index on account alone across services, so this
+ // is a linear scan — acceptable because credential counts are tiny.
+ var match = QueryAllItemsForApp(includeData: false)
+ .FirstOrDefault(item =>
+ string.Equals(item.Account, accountId, StringComparison.OrdinalIgnoreCase)
+ && !item.Service.EndsWith(SelectionsServiceSuffix, StringComparison.Ordinal));
+
+ return match is null ? null : (match.Service, match.Account);
}
- }
-
- // =========================
- // Keychain operations — each takes ownership of every CF object it
- // creates and releases in finally.
- // =========================
- private sealed class KeychainItem
- {
- public required string Service { get; init; }
- public string? Account { get; init; }
- public string? Label { get; init; }
- public string? Description { get; init; }
- public DateTime? CreatedAt { get; init; }
- public byte[]? Data { get; init; }
- }
+ // =========================
+ // Provider-name validation — mirrors FileCredentialManager rules so the
+ // two backends accept the same set of names.
+ // =========================
- private static void AddItem(KeychainItem item)
- {
- var handles = new List();
- try
- {
- var serviceCf = Track(handles, NewCfString(item.Service));
- var accountCf = Track(handles, NewCfString(item.Account ?? string.Empty));
- var labelCf = Track(handles, NewCfString(item.Label ?? string.Empty));
- var descCf = Track(handles, NewCfString(item.Description ?? string.Empty));
- var dataCf = Track(handles, NewCfData(item.Data ?? []));
-
- var pairs = new List<(IntPtr, IntPtr)>
- {
- (Constants.KSecClass, Constants.KSecClassGenericPassword),
- (Constants.KSecAttrService, serviceCf),
- (Constants.KSecAttrAccount, accountCf),
- (Constants.KSecAttrLabel, labelCf),
- (Constants.KSecAttrDescription, descCf),
- (Constants.KSecValueData, dataCf),
- };
-
- var query = Track(handles, NewCfDictionary(pairs));
-
- var status = SecItemAdd(query, out _);
- ThrowIfError(status, "SecItemAdd");
- }
- finally
+ private static void ValidateProviderName(string providerName)
{
- ReleaseAll(handles);
+ ArgumentException.ThrowIfNullOrWhiteSpace(providerName);
+ if (providerName.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '.' && c != '_' && c != '-'))
+ {
+ throw new ArgumentException(
+ $"Provider name '{providerName}' contains invalid characters. Allowed: ASCII letters, digits, '.', '_', '-'.",
+ nameof(providerName));
+ }
}
- }
- private static void UpdateItemData(string service, string account, byte[] data)
- {
- var handles = new List();
- try
- {
- var serviceCf = Track(handles, NewCfString(service));
- var accountCf = Track(handles, NewCfString(account));
- var query = Track(handles, NewCfDictionary(
- [
- (Constants.KSecClass, Constants.KSecClassGenericPassword),
- (Constants.KSecAttrService, serviceCf),
- (Constants.KSecAttrAccount, accountCf),
- ]));
+ private static bool IsValidAccountId(string? accountId) =>
+ !string.IsNullOrWhiteSpace(accountId) && Guid.TryParse(accountId, out _);
- var dataCf = Track(handles, NewCfData(data));
- var updateAttrs = Track(handles, NewCfDictionary(
- [
- (Constants.KSecValueData, dataCf),
- ]));
-
- var status = SecItemUpdate(query, updateAttrs);
- ThrowIfError(status, "SecItemUpdate");
- }
- finally
+ private static void ValidateAccountId(string accountId)
{
- ReleaseAll(handles);
+ ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
+ if (!Guid.TryParse(accountId, out _))
+ {
+ throw new ArgumentException(
+ $"Account id '{accountId}' is not a valid GUID.",
+ nameof(accountId));
+ }
}
- }
- private static void DeleteItem(string service, string account)
- {
- var handles = new List();
- try
- {
- var serviceCf = Track(handles, NewCfString(service));
- var accountCf = Track(handles, NewCfString(account));
- var query = Track(handles, NewCfDictionary(
- [
- (Constants.KSecClass, Constants.KSecClassGenericPassword),
- (Constants.KSecAttrService, serviceCf),
- (Constants.KSecAttrAccount, accountCf),
- ]));
+ // =========================
+ // Keychain operations — each takes ownership of every CF object it
+ // creates and releases in finally.
+ // =========================
- var status = SecItemDelete(query);
- if (status == ErrSecItemNotFound) return;
- ThrowIfError(status, "SecItemDelete");
- }
- finally
+ private sealed class KeychainItem
{
- ReleaseAll(handles);
+ public required string Service { get; init; }
+ public string? Account { get; init; }
+ public string? Label { get; init; }
+ public string? Description { get; init; }
+ public DateTime? CreatedAt { get; init; }
+ public byte[]? Data { get; init; }
}
- }
-
- private static KeychainItem? QuerySingleItem(string service, string account, bool includeData)
- {
- var handles = new List();
- try
- {
- var serviceCf = Track(handles, NewCfString(service));
- var accountCf = Track(handles, NewCfString(account));
- var query = Track(handles, NewCfDictionary(
- [
- (Constants.KSecClass, Constants.KSecClassGenericPassword),
- (Constants.KSecAttrService, serviceCf),
- (Constants.KSecAttrAccount, accountCf),
- (Constants.KSecMatchLimit, Constants.KSecMatchLimitOne),
- (Constants.KSecReturnAttributes, Constants.KCFBooleanTrue),
- (Constants.KSecReturnData, includeData ? Constants.KCFBooleanTrue : IntPtr.Zero),
- ]));
-
- var status = SecItemCopyMatching(query, out var result);
- if (status == ErrSecItemNotFound) return null;
- ThrowIfError(status, "SecItemCopyMatching");
+ private static void AddItem(KeychainItem item)
+ {
+ var handles = new List();
try
{
- return DecodeItem(result);
+ var serviceCf = Track(handles, NewCfString(item.Service));
+ var accountCf = Track(handles, NewCfString(item.Account ?? string.Empty));
+ var labelCf = Track(handles, NewCfString(item.Label ?? string.Empty));
+ var descCf = Track(handles, NewCfString(item.Description ?? string.Empty));
+ var dataCf = Track(handles, NewCfData(item.Data ?? []));
+
+ var pairs = new List<(IntPtr, IntPtr)>
+ {
+ (Constants.KSecClass, Constants.KSecClassGenericPassword),
+ (Constants.KSecAttrService, serviceCf),
+ (Constants.KSecAttrAccount, accountCf),
+ (Constants.KSecAttrLabel, labelCf),
+ (Constants.KSecAttrDescription, descCf),
+ (Constants.KSecValueData, dataCf),
+ };
+
+ var query = Track(handles, NewCfDictionary(pairs));
+
+ var status = SecItemAdd(query, out _);
+ ThrowIfError(status, "SecItemAdd");
}
finally
{
- if (result != IntPtr.Zero) CFRelease(result);
+ ReleaseAll(handles);
}
}
- finally
+
+ private static void UpdateItemData(string service, string account, byte[] data)
{
- ReleaseAll(handles);
+ var handles = new List();
+ try
+ {
+ var serviceCf = Track(handles, NewCfString(service));
+ var accountCf = Track(handles, NewCfString(account));
+ var query = Track(handles, NewCfDictionary(
+ [
+ (Constants.KSecClass, Constants.KSecClassGenericPassword),
+ (Constants.KSecAttrService, serviceCf),
+ (Constants.KSecAttrAccount, accountCf),
+ ]));
+
+ var dataCf = Track(handles, NewCfData(data));
+ var updateAttrs = Track(handles, NewCfDictionary(
+ [
+ (Constants.KSecValueData, dataCf),
+ ]));
+
+ var status = SecItemUpdate(query, updateAttrs);
+ ThrowIfError(status, "SecItemUpdate");
+ }
+ finally
+ {
+ ReleaseAll(handles);
+ }
}
- }
-
- private static List QueryItems(string service, bool includeData)
- {
- // Bulk query: request attributes only. Combining kSecReturnAttributes
- // + kSecReturnData + kSecMatchLimitAll in a single SecItemCopyMatching
- // call fails with errSecParam (-50) on macOS — Security.framework only
- // supports that combination with kSecMatchLimitOne. When data is
- // needed, we do a per-item follow-up below.
- List stubs;
- var handles = new List();
- try
- {
- var serviceCf = Track(handles, NewCfString(service));
- var query = Track(handles, NewCfDictionary(
- [
- (Constants.KSecClass, Constants.KSecClassGenericPassword),
- (Constants.KSecAttrService, serviceCf),
- (Constants.KSecMatchLimit, Constants.KSecMatchLimitAll),
- (Constants.KSecReturnAttributes, Constants.KCFBooleanTrue),
- ]));
-
- var status = SecItemCopyMatching(query, out var result);
- if (status == ErrSecItemNotFound) return [];
- ThrowIfError(status, "SecItemCopyMatching");
+ private static void DeleteItem(string service, string account)
+ {
+ var handles = new List();
try
{
- stubs = DecodeArray(result);
+ var serviceCf = Track(handles, NewCfString(service));
+ var accountCf = Track(handles, NewCfString(account));
+ var query = Track(handles, NewCfDictionary(
+ [
+ (Constants.KSecClass, Constants.KSecClassGenericPassword),
+ (Constants.KSecAttrService, serviceCf),
+ (Constants.KSecAttrAccount, accountCf),
+ ]));
+
+ var status = SecItemDelete(query);
+ if (status == ErrSecItemNotFound)
+ {
+ return;
+ }
+
+ ThrowIfError(status, "SecItemDelete");
}
finally
{
- if (result != IntPtr.Zero) CFRelease(result);
+ ReleaseAll(handles);
}
}
- finally
+
+ private static KeychainItem? QuerySingleItem(string service, string account, bool includeData)
{
- ReleaseAll(handles);
+ var handles = new List();
+ try
+ {
+ var serviceCf = Track(handles, NewCfString(service));
+ var accountCf = Track(handles, NewCfString(account));
+ var query = Track(handles, NewCfDictionary(
+ [
+ (Constants.KSecClass, Constants.KSecClassGenericPassword),
+ (Constants.KSecAttrService, serviceCf),
+ (Constants.KSecAttrAccount, accountCf),
+ (Constants.KSecMatchLimit, Constants.KSecMatchLimitOne),
+ (Constants.KSecReturnAttributes, Constants.KCFBooleanTrue),
+ (Constants.KSecReturnData, includeData ? Constants.KCFBooleanTrue : IntPtr.Zero),
+ ]));
+
+ var status = SecItemCopyMatching(query, out var result);
+ if (status == ErrSecItemNotFound)
+ {
+ return null;
+ }
+
+ ThrowIfError(status, "SecItemCopyMatching");
+
+ try
+ {
+ return DecodeItem(result);
+ }
+ finally
+ {
+ if (result != IntPtr.Zero)
+ {
+ CFRelease(result);
+ }
+ }
+ }
+ finally
+ {
+ ReleaseAll(handles);
+ }
}
- if (!includeData) return stubs;
-
- // Data round-trip: per-item QuerySingleItem(includeData: true) uses
- // kSecMatchLimitOne which supports attributes+data in one call.
- var withData = new List(stubs.Count);
- foreach (var stub in stubs)
+ private static List QueryItems(string service, bool includeData)
{
- if (stub.Account is null)
+ // Bulk query: request attributes only. Combining kSecReturnAttributes
+ // + kSecReturnData + kSecMatchLimitAll in a single SecItemCopyMatching
+ // call fails with errSecParam (-50) on macOS — Security.framework only
+ // supports that combination with kSecMatchLimitOne. When data is
+ // needed, we do a per-item follow-up below.
+ List stubs;
+ var handles = new List();
+ try
{
- withData.Add(stub);
- continue;
+ var serviceCf = Track(handles, NewCfString(service));
+ var query = Track(handles, NewCfDictionary(
+ [
+ (Constants.KSecClass, Constants.KSecClassGenericPassword),
+ (Constants.KSecAttrService, serviceCf),
+ (Constants.KSecMatchLimit, Constants.KSecMatchLimitAll),
+ (Constants.KSecReturnAttributes, Constants.KCFBooleanTrue),
+ ]));
+
+ var status = SecItemCopyMatching(query, out var result);
+ if (status == ErrSecItemNotFound)
+ {
+ return [];
+ }
+
+ ThrowIfError(status, "SecItemCopyMatching");
+
+ try
+ {
+ stubs = DecodeArray(result);
+ }
+ finally
+ {
+ if (result != IntPtr.Zero)
+ {
+ CFRelease(result);
+ }
+ }
+ }
+ finally
+ {
+ ReleaseAll(handles);
}
- var full = QuerySingleItem(stub.Service, stub.Account, includeData: true);
- // If the item vanished between queries (theoretically possible
- // under concurrent modification), fall back to the attribute-only
- // stub rather than dropping it.
- withData.Add(full ?? stub);
- }
- return withData;
- }
+ if (!includeData)
+ {
+ return stubs;
+ }
- private List QueryAllItemsForApp(bool includeData)
- {
- // No per-service filter — just pull everything, then filter in-memory
- // to items whose service starts with our appIdentifier. Keychain
- // queries require *some* filter so we fall back to class-only and
- // trust the prefix check.
- //
- // Like QueryItems above, we fetch attributes only here; if data is
- // requested, a per-item follow-up runs on the filtered set.
- List stubs;
- var handles = new List();
- try
- {
- var query = Track(handles, NewCfDictionary(
- [
- (Constants.KSecClass, Constants.KSecClassGenericPassword),
- (Constants.KSecMatchLimit, Constants.KSecMatchLimitAll),
- (Constants.KSecReturnAttributes, Constants.KCFBooleanTrue),
- ]));
-
- var status = SecItemCopyMatching(query, out var result);
- if (status == ErrSecItemNotFound) return [];
- ThrowIfError(status, "SecItemCopyMatching");
+ // Data round-trip: per-item QuerySingleItem(includeData: true) uses
+ // kSecMatchLimitOne which supports attributes+data in one call.
+ var withData = new List(stubs.Count);
+ foreach (var stub in stubs)
+ {
+ if (stub.Account is null)
+ {
+ withData.Add(stub);
+ continue;
+ }
+
+ var full = QuerySingleItem(stub.Service, stub.Account, includeData: true);
+ // If the item vanished between queries (theoretically possible
+ // under concurrent modification), fall back to the attribute-only
+ // stub rather than dropping it.
+ withData.Add(full ?? stub);
+ }
+ return withData;
+ }
+ private List QueryAllItemsForApp(bool includeData)
+ {
+ // No per-service filter — just pull everything, then filter in-memory
+ // to items whose service starts with our appIdentifier. Keychain
+ // queries require *some* filter so we fall back to class-only and
+ // trust the prefix check.
+ //
+ // Like QueryItems above, we fetch attributes only here; if data is
+ // requested, a per-item follow-up runs on the filtered set.
+ List stubs;
+ var handles = new List();
try
{
- var items = DecodeArray(result);
- stubs = items
- .Where(i => i.Service.StartsWith(_appIdentifier + ".", StringComparison.Ordinal)
- || string.Equals(i.Service, SelectionsService, StringComparison.Ordinal))
- .ToList();
+ var query = Track(handles, NewCfDictionary(
+ [
+ (Constants.KSecClass, Constants.KSecClassGenericPassword),
+ (Constants.KSecMatchLimit, Constants.KSecMatchLimitAll),
+ (Constants.KSecReturnAttributes, Constants.KCFBooleanTrue),
+ ]));
+
+ var status = SecItemCopyMatching(query, out var result);
+ if (status == ErrSecItemNotFound)
+ {
+ return [];
+ }
+
+ ThrowIfError(status, "SecItemCopyMatching");
+
+ try
+ {
+ var items = DecodeArray(result);
+ stubs = [.. items
+ .Where(i => i.Service.StartsWith(_appIdentifier + ".", StringComparison.Ordinal)
+ || string.Equals(i.Service, SelectionsService, StringComparison.Ordinal))];
+ }
+ finally
+ {
+ if (result != IntPtr.Zero)
+ {
+ CFRelease(result);
+ }
+ }
}
finally
{
- if (result != IntPtr.Zero) CFRelease(result);
+ ReleaseAll(handles);
}
- }
- finally
- {
- ReleaseAll(handles);
+
+ if (!includeData)
+ {
+ return stubs;
+ }
+
+ var withData = new List(stubs.Count);
+ foreach (var stub in stubs)
+ {
+ if (stub.Account is null)
+ {
+ withData.Add(stub);
+ continue;
+ }
+ var full = QuerySingleItem(stub.Service, stub.Account, includeData: true);
+ withData.Add(full ?? stub);
+ }
+ return withData;
}
- if (!includeData) return stubs;
+ // =========================
+ // Decoding CF results
+ // =========================
- var withData = new List(stubs.Count);
- foreach (var stub in stubs)
+ private static List DecodeArray(IntPtr arrayOrDict)
{
- if (stub.Account is null)
+ if (arrayOrDict == IntPtr.Zero)
{
- withData.Add(stub);
- continue;
+ return [];
}
- var full = QuerySingleItem(stub.Service, stub.Account, includeData: true);
- withData.Add(full ?? stub);
- }
- return withData;
- }
- // =========================
- // Decoding CF results
- // =========================
+ // The result can be either a CFArray (match-limit-all, multiple items)
+ // or a single CFDictionary (match-limit-all + one result, or an older
+ // macOS quirk). Dispatch on CF type ID — probing a CFArray with
+ // CFDictionaryGetValue toll-free-bridges to [NSArray objectForKey:]
+ // which crashes the process.
+ var typeId = CFGetTypeID(arrayOrDict);
+ if (typeId == CFDictionaryGetTypeID())
+ {
+ var single = DecodeItem(arrayOrDict);
+ return single is null ? [] : [single];
+ }
- private static List DecodeArray(IntPtr arrayOrDict)
- {
- if (arrayOrDict == IntPtr.Zero) return [];
+ if (typeId != CFArrayGetTypeID())
+ {
+ // Unknown result type — safe fallback: treat as no results.
+ return [];
+ }
- // The result can be either a CFArray (match-limit-all, multiple items)
- // or a single CFDictionary (match-limit-all + one result, or an older
- // macOS quirk). Dispatch on CF type ID — probing a CFArray with
- // CFDictionaryGetValue toll-free-bridges to [NSArray objectForKey:]
- // which crashes the process.
- var typeId = CFGetTypeID(arrayOrDict);
- if (typeId == CFDictionaryGetTypeID())
- {
- var single = DecodeItem(arrayOrDict);
- return single is null ? [] : [single];
+ var count = CFArrayGetCount(arrayOrDict);
+ var results = new List((int)count);
+ for (long i = 0; i < count; i++)
+ {
+ var dict = CFArrayGetValueAtIndex(arrayOrDict, i);
+ var item = DecodeItem(dict);
+ if (item is not null)
+ {
+ results.Add(item);
+ }
+ }
+ return results;
}
- if (typeId != CFArrayGetTypeID())
+ private static KeychainItem? DecodeItem(IntPtr dict)
{
- // Unknown result type — safe fallback: treat as no results.
- return [];
+ if (dict == IntPtr.Zero)
+ {
+ return null;
+ }
+
+ var service = ReadCfStringAt(dict, Constants.KSecAttrService);
+ if (service is null)
+ {
+ return null;
+ }
+
+ return new KeychainItem
+ {
+ Service = service,
+ Account = ReadCfStringAt(dict, Constants.KSecAttrAccount),
+ Label = ReadCfStringAt(dict, Constants.KSecAttrLabel),
+ Description = ReadCfStringAt(dict, Constants.KSecAttrDescription),
+ CreatedAt = ReadCfDateAt(dict, Constants.KSecAttrCreationDate),
+ Data = ReadCfDataAt(dict, Constants.KSecValueData),
+ };
}
- var count = CFArrayGetCount(arrayOrDict);
- var results = new List((int)count);
- for (long i = 0; i < count; i++)
+ private static string? ReadCfStringAt(IntPtr dict, IntPtr key)
{
- var dict = CFArrayGetValueAtIndex(arrayOrDict, i);
- var item = DecodeItem(dict);
- if (item is not null) results.Add(item);
+ var value = CFDictionaryGetValue(dict, key);
+ return value == IntPtr.Zero ? null : ReadCfString(value);
}
- return results;
- }
-
- private static KeychainItem? DecodeItem(IntPtr dict)
- {
- if (dict == IntPtr.Zero) return null;
-
- var service = ReadCfStringAt(dict, Constants.KSecAttrService);
- if (service is null) return null;
- return new KeychainItem
+ private static byte[]? ReadCfDataAt(IntPtr dict, IntPtr key)
{
- Service = service,
- Account = ReadCfStringAt(dict, Constants.KSecAttrAccount),
- Label = ReadCfStringAt(dict, Constants.KSecAttrLabel),
- Description = ReadCfStringAt(dict, Constants.KSecAttrDescription),
- CreatedAt = ReadCfDateAt(dict, Constants.KSecAttrCreationDate),
- Data = ReadCfDataAt(dict, Constants.KSecValueData),
- };
- }
-
- private static string? ReadCfStringAt(IntPtr dict, IntPtr key)
- {
- var value = CFDictionaryGetValue(dict, key);
- return value == IntPtr.Zero ? null : ReadCfString(value);
- }
+ var value = CFDictionaryGetValue(dict, key);
+ return value == IntPtr.Zero ? null : ReadCfData(value);
+ }
- private static byte[]? ReadCfDataAt(IntPtr dict, IntPtr key)
- {
- var value = CFDictionaryGetValue(dict, key);
- return value == IntPtr.Zero ? null : ReadCfData(value);
- }
+ private static DateTime? ReadCfDateAt(IntPtr dict, IntPtr key)
+ {
+ var value = CFDictionaryGetValue(dict, key);
+ return value == IntPtr.Zero ? null : ReadCfDate(value);
+ }
- private static DateTime? ReadCfDateAt(IntPtr dict, IntPtr key)
- {
- var value = CFDictionaryGetValue(dict, key);
- return value == IntPtr.Zero ? null : ReadCfDate(value);
- }
+ // =========================
+ // Error handling + handle tracking
+ // =========================
- // =========================
- // Error handling + handle tracking
- // =========================
+ private static IntPtr Track(List handles, IntPtr handle)
+ {
+ if (handle != IntPtr.Zero)
+ {
+ handles.Add(handle);
+ }
- private static IntPtr Track(List handles, IntPtr handle)
- {
- if (handle != IntPtr.Zero) handles.Add(handle);
- return handle;
- }
+ return handle;
+ }
- private static void ReleaseAll(List handles)
- {
- foreach (var h in handles)
+ private static void ReleaseAll(List handles)
{
- CFRelease(h);
+ foreach (var h in handles)
+ {
+ CFRelease(h);
+ }
}
- }
- private static void ThrowIfError(int status, string operation)
- {
- if (status == ErrSecSuccess) return;
- if (status == ErrSecUserCanceled)
+ private static void ThrowIfError(int status, string operation)
{
- throw new InvalidOperationException($"{operation}: user cancelled the keychain prompt.");
+ if (status == ErrSecSuccess)
+ {
+ return;
+ }
+
+ if (status == ErrSecUserCanceled)
+ {
+ throw new InvalidOperationException($"{operation}: user cancelled the keychain prompt.");
+ }
+ throw new InvalidOperationException($"{operation} failed: OSStatus {status}.");
}
- throw new InvalidOperationException($"{operation} failed: OSStatus {status}.");
}
}
diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainInterop.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainInterop.cs
index 572bdfd..9c2c4e9 100644
--- a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainInterop.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainInterop.cs
@@ -1,290 +1,314 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
-using System.Text;
-
-namespace NextIteration.SpectreConsole.Auth.Persistence.Keychain;
-
-///
-/// P/Invoke surface for Apple Security.framework and CoreFoundation.
-/// Only the subset needed to implement generic-password keychain items is
-/// declared here. All methods are macOS-only and will fail at runtime on
-/// other platforms — callers must gate usage behind
-/// .
-///
-[SupportedOSPlatform("macos")]
-internal static partial class KeychainInterop
+
+namespace NextIteration.SpectreConsole.Auth.Persistence.Keychain
{
- private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
- private const string Security = "/System/Library/Frameworks/Security.framework/Security";
+ ///
+ /// P/Invoke surface for Apple Security.framework and CoreFoundation.
+ /// Only the subset needed to implement generic-password keychain items is
+ /// declared here. All methods are macOS-only and will fail at runtime on
+ /// other platforms — callers must gate usage behind
+ /// .
+ ///
+ [SupportedOSPlatform("macos")]
+ internal static partial class KeychainInterop
+ {
+ private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
+ private const string Security = "/System/Library/Frameworks/Security.framework/Security";
- // CFString encoding — kCFStringEncodingUTF8.
- internal const uint CFStringEncodingUtf8 = 0x08000100;
+ // CFString encoding — kCFStringEncodingUTF8.
+ internal const uint CFStringEncodingUtf8 = 0x08000100;
- // CFNumberType — kCFNumberSInt32Type / kCFNumberSInt64Type.
- internal const int CFNumberSInt32Type = 3;
+ // CFNumberType — kCFNumberSInt32Type / kCFNumberSInt64Type.
+ internal const int CFNumberSInt32Type = 3;
- // OSStatus codes we handle explicitly.
- internal const int ErrSecSuccess = 0;
- internal const int ErrSecItemNotFound = -25300;
- internal const int ErrSecDuplicateItem = -25299;
- internal const int ErrSecUserCanceled = -128;
+ // OSStatus codes we handle explicitly.
+ internal const int ErrSecSuccess = 0;
+ internal const int ErrSecItemNotFound = -25300;
+ internal const int ErrSecDuplicateItem = -25299;
+ internal const int ErrSecUserCanceled = -128;
- // =========================
- // CoreFoundation — memory
- // =========================
+ // =========================
+ // CoreFoundation — memory
+ // =========================
- [LibraryImport(CoreFoundation)]
- internal static partial void CFRelease(IntPtr cf);
+ [LibraryImport(CoreFoundation)]
+ internal static partial void CFRelease(IntPtr cf);
- // =========================
- // CoreFoundation — CFString
- // =========================
+ // =========================
+ // CoreFoundation — CFString
+ // =========================
- [LibraryImport(CoreFoundation, StringMarshalling = StringMarshalling.Utf8)]
- internal static partial IntPtr CFStringCreateWithCString(IntPtr allocator, string cStr, uint encoding);
+ [LibraryImport(CoreFoundation, StringMarshalling = StringMarshalling.Utf8)]
+ internal static partial IntPtr CFStringCreateWithCString(IntPtr allocator, string cStr, uint encoding);
- [LibraryImport(CoreFoundation)]
- internal static partial long CFStringGetLength(IntPtr theString);
+ [LibraryImport(CoreFoundation)]
+ internal static partial long CFStringGetLength(IntPtr theString);
- [LibraryImport(CoreFoundation)]
- internal static partial long CFStringGetMaximumSizeForEncoding(long length, uint encoding);
+ [LibraryImport(CoreFoundation)]
+ internal static partial long CFStringGetMaximumSizeForEncoding(long length, uint encoding);
- [LibraryImport(CoreFoundation)]
- [return: MarshalAs(UnmanagedType.U1)]
- internal static partial bool CFStringGetCString(IntPtr theString, IntPtr buffer, long bufferSize, uint encoding);
+ [LibraryImport(CoreFoundation)]
+ [return: MarshalAs(UnmanagedType.U1)]
+ internal static partial bool CFStringGetCString(IntPtr theString, IntPtr buffer, long bufferSize, uint encoding);
- // =========================
- // CoreFoundation — CFData
- // =========================
+ // =========================
+ // CoreFoundation — CFData
+ // =========================
- [LibraryImport(CoreFoundation)]
- internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, long length);
+ [LibraryImport(CoreFoundation)]
+ internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, long length);
- [LibraryImport(CoreFoundation)]
- internal static partial long CFDataGetLength(IntPtr theData);
+ [LibraryImport(CoreFoundation)]
+ internal static partial long CFDataGetLength(IntPtr theData);
- [LibraryImport(CoreFoundation)]
- internal static partial IntPtr CFDataGetBytePtr(IntPtr theData);
+ [LibraryImport(CoreFoundation)]
+ internal static partial IntPtr CFDataGetBytePtr(IntPtr theData);
- // =========================
- // CoreFoundation — CFDictionary
- // =========================
+ // =========================
+ // CoreFoundation — CFDictionary
+ // =========================
- [LibraryImport(CoreFoundation)]
- internal static partial IntPtr CFDictionaryCreateMutable(
- IntPtr allocator, long capacity, IntPtr keyCallBacks, IntPtr valueCallBacks);
+ [LibraryImport(CoreFoundation)]
+ internal static partial IntPtr CFDictionaryCreateMutable(
+ IntPtr allocator, long capacity, IntPtr keyCallBacks, IntPtr valueCallBacks);
- [LibraryImport(CoreFoundation)]
- internal static partial void CFDictionarySetValue(IntPtr theDict, IntPtr key, IntPtr value);
+ [LibraryImport(CoreFoundation)]
+ internal static partial void CFDictionarySetValue(IntPtr theDict, IntPtr key, IntPtr value);
- [LibraryImport(CoreFoundation)]
- internal static partial IntPtr CFDictionaryGetValue(IntPtr theDict, IntPtr key);
+ [LibraryImport(CoreFoundation)]
+ internal static partial IntPtr CFDictionaryGetValue(IntPtr theDict, IntPtr key);
- // =========================
- // CoreFoundation — CFArray
- // =========================
+ // =========================
+ // CoreFoundation — CFArray
+ // =========================
- [LibraryImport(CoreFoundation)]
- internal static partial long CFArrayGetCount(IntPtr theArray);
+ [LibraryImport(CoreFoundation)]
+ internal static partial long CFArrayGetCount(IntPtr theArray);
- [LibraryImport(CoreFoundation)]
- internal static partial IntPtr CFArrayGetValueAtIndex(IntPtr theArray, long idx);
+ [LibraryImport(CoreFoundation)]
+ internal static partial IntPtr CFArrayGetValueAtIndex(IntPtr theArray, long idx);
- // =========================
- // CoreFoundation — CFType introspection
- //
- // Used to distinguish CFArray vs CFDictionary results from
- // SecItemCopyMatching. Probing a CFArray with CFDictionaryGetValue
- // toll-free-bridges to [NSArray objectForKey:] which doesn't exist and
- // raises NSInvalidArgumentException — crashing the process. Checking the
- // CF type ID first is the supported way.
- // =========================
+ // =========================
+ // CoreFoundation — CFType introspection
+ //
+ // Used to distinguish CFArray vs CFDictionary results from
+ // SecItemCopyMatching. Probing a CFArray with CFDictionaryGetValue
+ // toll-free-bridges to [NSArray objectForKey:] which doesn't exist and
+ // raises NSInvalidArgumentException — crashing the process. Checking the
+ // CF type ID first is the supported way.
+ // =========================
- [LibraryImport(CoreFoundation)]
- internal static partial UIntPtr CFGetTypeID(IntPtr cf);
+ [LibraryImport(CoreFoundation)]
+ internal static partial UIntPtr CFGetTypeID(IntPtr cf);
- [LibraryImport(CoreFoundation)]
- internal static partial UIntPtr CFArrayGetTypeID();
+ [LibraryImport(CoreFoundation)]
+ internal static partial UIntPtr CFArrayGetTypeID();
- [LibraryImport(CoreFoundation)]
- internal static partial UIntPtr CFDictionaryGetTypeID();
+ [LibraryImport(CoreFoundation)]
+ internal static partial UIntPtr CFDictionaryGetTypeID();
- // =========================
- // CoreFoundation — CFDate / CFNumber
- // =========================
+ // =========================
+ // CoreFoundation — CFDate / CFNumber
+ // =========================
- [LibraryImport(CoreFoundation)]
- internal static partial double CFDateGetAbsoluteTime(IntPtr theDate);
+ [LibraryImport(CoreFoundation)]
+ internal static partial double CFDateGetAbsoluteTime(IntPtr theDate);
- [LibraryImport(CoreFoundation)]
- [return: MarshalAs(UnmanagedType.U1)]
- internal static partial bool CFNumberGetValue(IntPtr number, long theType, out int value);
+ [LibraryImport(CoreFoundation)]
+ [return: MarshalAs(UnmanagedType.U1)]
+ internal static partial bool CFNumberGetValue(IntPtr number, long theType, out int value);
- // =========================
- // Security.framework
- // =========================
+ // =========================
+ // Security.framework
+ // =========================
- [LibraryImport(Security)]
- internal static partial int SecItemAdd(IntPtr query, out IntPtr result);
+ [LibraryImport(Security)]
+ internal static partial int SecItemAdd(IntPtr query, out IntPtr result);
- [LibraryImport(Security)]
- internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
+ [LibraryImport(Security)]
+ internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
- [LibraryImport(Security)]
- internal static partial int SecItemUpdate(IntPtr query, IntPtr attributesToUpdate);
+ [LibraryImport(Security)]
+ internal static partial int SecItemUpdate(IntPtr query, IntPtr attributesToUpdate);
- [LibraryImport(Security)]
- internal static partial int SecItemDelete(IntPtr query);
+ [LibraryImport(Security)]
+ internal static partial int SecItemDelete(IntPtr query);
- // =========================
- // kSec* / kCF* data constants — loaded once via dlopen/dlsym at first use.
- // These are CFString/CFBoolean globals exported from the frameworks. The
- // symbol yields a pointer TO a CFTypeRef, so we read one IntPtr from it.
- // =========================
+ // =========================
+ // kSec* / kCF* data constants — loaded once via dlopen/dlsym at first use.
+ // These are CFString/CFBoolean globals exported from the frameworks. The
+ // symbol yields a pointer TO a CFTypeRef, so we read one IntPtr from it.
+ // =========================
- internal static class Constants
- {
- // Security.framework item-class keys.
- internal static readonly IntPtr KSecClass = LoadSymbol(Security, "kSecClass");
- internal static readonly IntPtr KSecClassGenericPassword = LoadSymbol(Security, "kSecClassGenericPassword");
-
- // Attribute keys we write on each item.
- internal static readonly IntPtr KSecAttrService = LoadSymbol(Security, "kSecAttrService");
- internal static readonly IntPtr KSecAttrAccount = LoadSymbol(Security, "kSecAttrAccount");
- internal static readonly IntPtr KSecAttrLabel = LoadSymbol(Security, "kSecAttrLabel");
- internal static readonly IntPtr KSecAttrDescription = LoadSymbol(Security, "kSecAttrDescription");
- internal static readonly IntPtr KSecAttrGeneric = LoadSymbol(Security, "kSecAttrGeneric");
- internal static readonly IntPtr KSecAttrCreationDate = LoadSymbol(Security, "kSecAttrCreationDate");
- internal static readonly IntPtr KSecValueData = LoadSymbol(Security, "kSecValueData");
-
- // Query modifiers.
- internal static readonly IntPtr KSecMatchLimit = LoadSymbol(Security, "kSecMatchLimit");
- internal static readonly IntPtr KSecMatchLimitAll = LoadSymbol(Security, "kSecMatchLimitAll");
- internal static readonly IntPtr KSecMatchLimitOne = LoadSymbol(Security, "kSecMatchLimitOne");
- internal static readonly IntPtr KSecReturnAttributes = LoadSymbol(Security, "kSecReturnAttributes");
- internal static readonly IntPtr KSecReturnData = LoadSymbol(Security, "kSecReturnData");
-
- // CoreFoundation boolean singletons.
- internal static readonly IntPtr KCFBooleanTrue = LoadSymbol(CoreFoundation, "kCFBooleanTrue");
-
- private static IntPtr LoadSymbol(string library, string symbolName)
+ internal static class Constants
{
- var libHandle = NativeLibrary.Load(library);
- // The symbol is a pointer TO a CFTypeRef — dereference one IntPtr.
- var address = NativeLibrary.GetExport(libHandle, symbolName);
- return Marshal.ReadIntPtr(address);
+ // Security.framework item-class keys.
+ internal static readonly IntPtr KSecClass = LoadSymbol(Security, "kSecClass");
+ internal static readonly IntPtr KSecClassGenericPassword = LoadSymbol(Security, "kSecClassGenericPassword");
+
+ // Attribute keys we write on each item.
+ internal static readonly IntPtr KSecAttrService = LoadSymbol(Security, "kSecAttrService");
+ internal static readonly IntPtr KSecAttrAccount = LoadSymbol(Security, "kSecAttrAccount");
+ internal static readonly IntPtr KSecAttrLabel = LoadSymbol(Security, "kSecAttrLabel");
+ internal static readonly IntPtr KSecAttrDescription = LoadSymbol(Security, "kSecAttrDescription");
+ internal static readonly IntPtr KSecAttrGeneric = LoadSymbol(Security, "kSecAttrGeneric");
+ internal static readonly IntPtr KSecAttrCreationDate = LoadSymbol(Security, "kSecAttrCreationDate");
+ internal static readonly IntPtr KSecValueData = LoadSymbol(Security, "kSecValueData");
+
+ // Query modifiers.
+ internal static readonly IntPtr KSecMatchLimit = LoadSymbol(Security, "kSecMatchLimit");
+ internal static readonly IntPtr KSecMatchLimitAll = LoadSymbol(Security, "kSecMatchLimitAll");
+ internal static readonly IntPtr KSecMatchLimitOne = LoadSymbol(Security, "kSecMatchLimitOne");
+ internal static readonly IntPtr KSecReturnAttributes = LoadSymbol(Security, "kSecReturnAttributes");
+ internal static readonly IntPtr KSecReturnData = LoadSymbol(Security, "kSecReturnData");
+
+ // CoreFoundation boolean singletons.
+ internal static readonly IntPtr KCFBooleanTrue = LoadSymbol(CoreFoundation, "kCFBooleanTrue");
+
+ private static IntPtr LoadSymbol(string library, string symbolName)
+ {
+ var libHandle = NativeLibrary.Load(library);
+ // The symbol is a pointer TO a CFTypeRef — dereference one IntPtr.
+ var address = NativeLibrary.GetExport(libHandle, symbolName);
+ return Marshal.ReadIntPtr(address);
+ }
}
- }
- // =========================
- // Managed helpers over the raw P/Invoke — each takes ownership of the
- // returned CF handle and must be released by the caller.
- // =========================
+ // =========================
+ // Managed helpers over the raw P/Invoke — each takes ownership of the
+ // returned CF handle and must be released by the caller.
+ // =========================
- ///
- /// Creates a CFString from a UTF-8 .NET string. Caller must
- /// the returned handle.
- ///
- internal static IntPtr NewCfString(string value)
- {
- var handle = CFStringCreateWithCString(IntPtr.Zero, value, CFStringEncodingUtf8);
- if (handle == IntPtr.Zero)
- throw new InvalidOperationException($"CFStringCreateWithCString failed for '{value}'.");
- return handle;
- }
-
- ///
- /// Creates a CFData from a byte array. Caller must
- /// the returned handle.
- ///
- internal static IntPtr NewCfData(ReadOnlySpan bytes)
- {
- unsafe
+ ///
+ /// Creates a CFString from a UTF-8 .NET string. Caller must
+ /// the returned handle.
+ ///
+ internal static IntPtr NewCfString(string value)
{
- fixed (byte* ptr = bytes)
+ var handle = CFStringCreateWithCString(IntPtr.Zero, value, CFStringEncodingUtf8);
+ if (handle == IntPtr.Zero)
{
- var handle = CFDataCreate(IntPtr.Zero, (IntPtr)ptr, bytes.Length);
- if (handle == IntPtr.Zero)
- throw new InvalidOperationException("CFDataCreate failed.");
- return handle;
+ throw new InvalidOperationException($"CFStringCreateWithCString failed for '{value}'.");
}
- }
- }
-
- ///
- /// Reads a CFString back into a managed string (UTF-8).
- ///
- internal static string ReadCfString(IntPtr cfString)
- {
- if (cfString == IntPtr.Zero) return string.Empty;
- var length = CFStringGetLength(cfString);
- var maxBytes = CFStringGetMaximumSizeForEncoding(length, CFStringEncodingUtf8) + 1;
+ return handle;
+ }
- var buffer = Marshal.AllocHGlobal(checked((IntPtr)maxBytes));
- try
+ ///
+ /// Creates a CFData from a byte array. Caller must
+ /// the returned handle.
+ ///
+ internal static IntPtr NewCfData(ReadOnlySpan bytes)
{
- if (!CFStringGetCString(cfString, buffer, maxBytes, CFStringEncodingUtf8))
- throw new InvalidOperationException("CFStringGetCString failed.");
- return Marshal.PtrToStringUTF8(buffer) ?? string.Empty;
+ unsafe
+ {
+ fixed (byte* ptr = bytes)
+ {
+ var handle = CFDataCreate(IntPtr.Zero, (IntPtr)ptr, bytes.Length);
+ if (handle == IntPtr.Zero)
+ {
+ throw new InvalidOperationException("CFDataCreate failed.");
+ }
+
+ return handle;
+ }
+ }
}
- finally
+
+ ///
+ /// Reads a CFString back into a managed string (UTF-8).
+ ///
+ internal static string ReadCfString(IntPtr cfString)
{
- Marshal.FreeHGlobal(buffer);
+ if (cfString == IntPtr.Zero)
+ {
+ return string.Empty;
+ }
+
+ var length = CFStringGetLength(cfString);
+ var maxBytes = CFStringGetMaximumSizeForEncoding(length, CFStringEncodingUtf8) + 1;
+
+ var buffer = Marshal.AllocHGlobal(checked((IntPtr)maxBytes));
+ try
+ {
+ if (!CFStringGetCString(cfString, buffer, maxBytes, CFStringEncodingUtf8))
+ {
+ throw new InvalidOperationException("CFStringGetCString failed.");
+ }
+
+ return Marshal.PtrToStringUTF8(buffer) ?? string.Empty;
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(buffer);
+ }
}
- }
- ///
- /// Reads a CFData back into a managed byte array.
- ///
- internal static byte[] ReadCfData(IntPtr cfData)
- {
- if (cfData == IntPtr.Zero) return [];
+ ///
+ /// Reads a CFData back into a managed byte array.
+ ///
+ internal static byte[] ReadCfData(IntPtr cfData)
+ {
+ if (cfData == IntPtr.Zero)
+ {
+ return [];
+ }
- var length = (int)CFDataGetLength(cfData);
- if (length == 0) return [];
+ var length = (int)CFDataGetLength(cfData);
+ if (length == 0)
+ {
+ return [];
+ }
- var bytesPtr = CFDataGetBytePtr(cfData);
- var managed = new byte[length];
- Marshal.Copy(bytesPtr, managed, 0, length);
- return managed;
- }
+ var bytesPtr = CFDataGetBytePtr(cfData);
+ var managed = new byte[length];
+ Marshal.Copy(bytesPtr, managed, 0, length);
+ return managed;
+ }
- ///
- /// Reads a CFDate as UTC . CF absolute time is
- /// seconds since 2001-01-01T00:00:00Z.
- ///
- internal static DateTime ReadCfDate(IntPtr cfDate)
- {
- var cfEpoch = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc);
- var seconds = CFDateGetAbsoluteTime(cfDate);
- return cfEpoch.AddSeconds(seconds);
- }
+ ///
+ /// Reads a CFDate as UTC . CF absolute time is
+ /// seconds since 2001-01-01T00:00:00Z.
+ ///
+ internal static DateTime ReadCfDate(IntPtr cfDate)
+ {
+ var cfEpoch = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var seconds = CFDateGetAbsoluteTime(cfDate);
+ return cfEpoch.AddSeconds(seconds);
+ }
- ///
- /// Builds a mutable CFDictionary out of the supplied key/value pairs.
- /// Caller must the returned handle.
- ///
- internal static IntPtr NewCfDictionary(IReadOnlyList<(IntPtr Key, IntPtr Value)> pairs)
- {
- // kCFTypeDictionaryKeyCallBacks / kCFTypeDictionaryValueCallBacks
- // are the standard callbacks that retain/release CF types. We pass
- // IntPtr.Zero which makes the dictionary bag-of-pointers — fine for
- // our usage because all values we pass in remain alive for the
- // duration of the call.
- var dict = CFDictionaryCreateMutable(IntPtr.Zero, pairs.Count, IntPtr.Zero, IntPtr.Zero);
- if (dict == IntPtr.Zero)
- throw new InvalidOperationException("CFDictionaryCreateMutable failed.");
-
- foreach (var (key, value) in pairs)
+ ///
+ /// Builds a mutable CFDictionary out of the supplied key/value pairs.
+ /// Caller must the returned handle.
+ ///
+ internal static IntPtr NewCfDictionary(IReadOnlyList<(IntPtr Key, IntPtr Value)> pairs)
{
- // Skip pairs with a NULL value — callers use IntPtr.Zero as a
- // "don't include this key at all" marker (e.g. conditional
- // kSecReturnData). Sending a NULL-valued entry to
- // Security.framework causes errSecParam on some queries.
- if (value == IntPtr.Zero) continue;
- CFDictionarySetValue(dict, key, value);
+ // kCFTypeDictionaryKeyCallBacks / kCFTypeDictionaryValueCallBacks
+ // are the standard callbacks that retain/release CF types. We pass
+ // IntPtr.Zero which makes the dictionary bag-of-pointers — fine for
+ // our usage because all values we pass in remain alive for the
+ // duration of the call.
+ var dict = CFDictionaryCreateMutable(IntPtr.Zero, pairs.Count, IntPtr.Zero, IntPtr.Zero);
+ if (dict == IntPtr.Zero)
+ {
+ throw new InvalidOperationException("CFDictionaryCreateMutable failed.");
+ }
+
+ foreach (var (key, value) in pairs)
+ {
+ // Skip pairs with a NULL value — callers use IntPtr.Zero as a
+ // "don't include this key at all" marker (e.g. conditional
+ // kSecReturnData). Sending a NULL-valued entry to
+ // Security.framework causes errSecParam on some queries.
+ if (value == IntPtr.Zero)
+ {
+ continue;
+ }
+
+ CFDictionarySetValue(dict, key, value);
+ }
+ return dict;
}
- return dict;
}
}
diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs
index d4ab5f0..c042a1e 100644
--- a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs
@@ -4,533 +4,574 @@
using static NextIteration.SpectreConsole.Auth.Persistence.Libsecret.LibsecretInterop;
-namespace NextIteration.SpectreConsole.Auth.Persistence.Libsecret;
-
-///
-/// implementation backed by the Secret
-/// Service API (libsecret). Each credential becomes a libsecret item in
-/// the user's default keyring (GNOME Keyring, KWallet's shim, etc.).
-///
-///
-///
-/// This backend is marked experimental. Tested against
-/// gnome-keyring-daemon on Ubuntu; behaviour on other Secret
-/// Service implementations (KWallet, kwallet-secrets, or the
-/// pass shim) has not been verified.
-///
-///
-/// Requires a running Secret Service daemon. Headless containers and
-/// SSH-only servers typically don't have one; calls will throw at the
-/// first operation. Consumers building for unattended environments should
-/// fall back to .
-///
-///
-[SupportedOSPlatform("linux")]
-public sealed class LibsecretCredentialManager : ICredentialManager
+namespace NextIteration.SpectreConsole.Auth.Persistence.Libsecret
{
- // Attribute keys on each libsecret item — scoped under our app so
- // queries don't collide with other tools using the same keyring.
- private const string AttrApp = "nextIteration.sca.app";
- private const string AttrKind = "nextIteration.sca.kind"; // "credential" | "selection"
- private const string AttrProvider = "nextIteration.sca.provider";
- private const string AttrAccount = "nextIteration.sca.account";
- private const string AttrLabel = "nextIteration.sca.label"; // user-supplied account name
- private const string AttrEnvironment = "nextIteration.sca.environment";
- private const string AttrCreatedAt = "nextIteration.sca.createdAt"; // ISO-8601 UTC
-
- private const string KindCredential = "credential";
- private const string KindSelection = "selection";
-
- private readonly string _appIdentifier;
- private readonly string _collection;
- private readonly Dictionary _summaryProviders;
-
///
- /// Constructs the manager. scopes this
- /// CLI's items in the keyring so they don't collide with other tools
- /// using the same keyring (e.g. com.mycompany.my-cli).
- /// selects the Secret Service collection
- /// that new items are written to; defaults to "default" (usually
- /// the login keyring). Pass "session" to target the in-memory
- /// session collection, which always exists on a running daemon.
+ /// implementation backed by the Secret
+ /// Service API (libsecret). Each credential becomes a libsecret item in
+ /// the user's default keyring (GNOME Keyring, KWallet's shim, etc.).
///
- public LibsecretCredentialManager(
- string appIdentifier,
- IEnumerable? summaryProviders = null,
- string collection = "default")
+ ///
+ ///
+ /// This backend is marked experimental. Tested against
+ /// gnome-keyring-daemon on Ubuntu; behaviour on other Secret
+ /// Service implementations (KWallet, kwallet-secrets, or the
+ /// pass shim) has not been verified.
+ ///
+ ///
+ /// Requires a running Secret Service daemon. Headless containers and
+ /// SSH-only servers typically don't have one; calls will throw at the
+ /// first operation. Consumers building for unattended environments should
+ /// fall back to .
+ ///
+ ///
+ [SupportedOSPlatform("linux")]
+ public sealed class LibsecretCredentialManager : ICredentialManager
{
- ArgumentException.ThrowIfNullOrWhiteSpace(appIdentifier);
- ArgumentException.ThrowIfNullOrWhiteSpace(collection);
- if (!OperatingSystem.IsLinux())
+ // Attribute keys on each libsecret item — scoped under our app so
+ // queries don't collide with other tools using the same keyring.
+ private const string AttrApp = "nextIteration.sca.app";
+ private const string AttrKind = "nextIteration.sca.kind"; // "credential" | "selection"
+ private const string AttrProvider = "nextIteration.sca.provider";
+ private const string AttrAccount = "nextIteration.sca.account";
+ private const string AttrLabel = "nextIteration.sca.label"; // user-supplied account name
+ private const string AttrEnvironment = "nextIteration.sca.environment";
+ private const string AttrCreatedAt = "nextIteration.sca.createdAt"; // ISO-8601 UTC
+
+ private const string KindCredential = "credential";
+ private const string KindSelection = "selection";
+
+ private readonly string _appIdentifier;
+ private readonly string _collection;
+ private readonly Dictionary _summaryProviders;
+
+ ///
+ /// Constructs the manager. scopes this
+ /// CLI's items in the keyring so they don't collide with other tools
+ /// using the same keyring (e.g. com.mycompany.my-cli).
+ /// selects the Secret Service collection
+ /// that new items are written to; defaults to "default" (usually
+ /// the login keyring). Pass "session" to target the in-memory
+ /// session collection, which always exists on a running daemon.
+ ///
+ public LibsecretCredentialManager(
+ string appIdentifier,
+ IEnumerable? summaryProviders = null,
+ string collection = "default")
{
- throw new PlatformNotSupportedException("LibsecretCredentialManager is only available on Linux.");
- }
-
- _appIdentifier = appIdentifier;
- _collection = collection;
- _summaryProviders = (summaryProviders ?? [])
- .ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase);
- }
+ ArgumentException.ThrowIfNullOrWhiteSpace(appIdentifier);
+ ArgumentException.ThrowIfNullOrWhiteSpace(collection);
+ if (!OperatingSystem.IsLinux())
+ {
+ throw new PlatformNotSupportedException("LibsecretCredentialManager is only available on Linux.");
+ }
- ///
- public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData)
- {
- ValidateProviderName(providerName);
- var accountId = Guid.NewGuid().ToString();
+ _appIdentifier = appIdentifier;
+ _collection = collection;
+ _summaryProviders = (summaryProviders ?? [])
+ .ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase);
+ }
- var attrs = new Dictionary(StringComparer.Ordinal)
+ ///
+ public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData)
{
- [AttrApp] = _appIdentifier,
- [AttrKind] = KindCredential,
- [AttrProvider] = providerName,
- [AttrAccount] = accountId,
- [AttrLabel] = accountName,
- [AttrEnvironment] = environment,
- [AttrCreatedAt] = DateTime.UtcNow.ToString("O"),
- };
- var label = $"{_appIdentifier}: {providerName}/{accountName}";
-
- StoreItem(attrs, label, credentialData);
- return Task.FromResult(accountId);
- }
+ ValidateProviderName(providerName);
+ var accountId = Guid.NewGuid().ToString();
- ///
- public Task> ListCredentialsAsync(string providerName)
- {
- ValidateProviderName(providerName);
- _summaryProviders.TryGetValue(providerName, out var summaryProvider);
-
- var selectedId = ReadSelection(providerName);
- var items = SearchItems(
- new Dictionary(StringComparer.Ordinal)
+ var attrs = new Dictionary(StringComparer.Ordinal)
{
[AttrApp] = _appIdentifier,
[AttrKind] = KindCredential,
[AttrProvider] = providerName,
- },
- loadSecrets: summaryProvider is not null);
+ [AttrAccount] = accountId,
+ [AttrLabel] = accountName,
+ [AttrEnvironment] = environment,
+ [AttrCreatedAt] = DateTime.UtcNow.ToString("O"),
+ };
+ var label = $"{_appIdentifier}: {providerName}/{accountName}";
+
+ StoreItem(attrs, label, credentialData);
+ return Task.FromResult(accountId);
+ }
+
+ ///
+ public Task> ListCredentialsAsync(string providerName)
+ {
+ ValidateProviderName(providerName);
+ _summaryProviders.TryGetValue(providerName, out var summaryProvider);
- var result = items
- .Select(i => new CredentialSummary
+ var selectedId = ReadSelection(providerName);
+ var items = SearchItems(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [AttrApp] = _appIdentifier,
+ [AttrKind] = KindCredential,
+ [AttrProvider] = providerName,
+ },
+ loadSecrets: summaryProvider is not null);
+
+ var result = items
+ .Select(i => new CredentialSummary
+ {
+ AccountId = i.Attributes.GetValueOrDefault(AttrAccount, string.Empty),
+ AccountName = i.Attributes.GetValueOrDefault(AttrLabel, string.Empty),
+ ProviderName = providerName,
+ Environment = i.Attributes.GetValueOrDefault(AttrEnvironment, string.Empty),
+ CreatedAt = ParseCreatedAt(i.Attributes.GetValueOrDefault(AttrCreatedAt)),
+ IsSelected = selectedId is not null && string.Equals(
+ selectedId, i.Attributes.GetValueOrDefault(AttrAccount), StringComparison.OrdinalIgnoreCase),
+ DisplayFields = summaryProvider is not null && i.Secret is not null
+ ? summaryProvider.GetDisplayFields(i.Secret)
+ : [],
+ })
+ .OrderBy(c => c.AccountName)
+ .ToList();
+
+ return Task.FromResult>(result);
+ }
+
+ ///
+ public Task DeleteCredentialAsync(string accountId)
+ {
+ if (!IsValidAccountId(accountId))
{
- AccountId = i.Attributes.GetValueOrDefault(AttrAccount, string.Empty),
- AccountName = i.Attributes.GetValueOrDefault(AttrLabel, string.Empty),
- ProviderName = providerName,
- Environment = i.Attributes.GetValueOrDefault(AttrEnvironment, string.Empty),
- CreatedAt = ParseCreatedAt(i.Attributes.GetValueOrDefault(AttrCreatedAt)),
- IsSelected = selectedId is not null && string.Equals(
- selectedId, i.Attributes.GetValueOrDefault(AttrAccount), StringComparison.OrdinalIgnoreCase),
- DisplayFields = summaryProvider is not null && i.Secret is not null
- ? summaryProvider.GetDisplayFields(i.Secret)
- : [],
- })
- .OrderBy(c => c.AccountName)
- .ToList();
-
- return Task.FromResult>(result);
- }
+ return Task.FromResult(false);
+ }
- ///
- public Task DeleteCredentialAsync(string accountId)
- {
- if (!IsValidAccountId(accountId)) return Task.FromResult(false);
+ // Find the item so we know its provider (for selection cleanup).
+ var match = SearchItems(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [AttrApp] = _appIdentifier,
+ [AttrKind] = KindCredential,
+ [AttrAccount] = accountId,
+ },
+ loadSecrets: false).FirstOrDefault();
+
+ if (match is null)
+ {
+ return Task.FromResult(false);
+ }
- // Find the item so we know its provider (for selection cleanup).
- var match = SearchItems(
- new Dictionary(StringComparer.Ordinal)
+ ClearItem(new Dictionary(StringComparer.Ordinal)
{
[AttrApp] = _appIdentifier,
[AttrKind] = KindCredential,
[AttrAccount] = accountId,
- },
- loadSecrets: false).FirstOrDefault();
+ });
- if (match is null) return Task.FromResult(false);
+ var providerName = match.Attributes.GetValueOrDefault(AttrProvider);
+ if (providerName is not null)
+ {
+ var selected = ReadSelection(providerName);
+ if (string.Equals(selected, accountId, StringComparison.OrdinalIgnoreCase))
+ {
+ ClearSelection(providerName);
+ }
+ }
- ClearItem(new Dictionary(StringComparer.Ordinal)
- {
- [AttrApp] = _appIdentifier,
- [AttrKind] = KindCredential,
- [AttrAccount] = accountId,
- });
+ return Task.FromResult(true);
+ }
- var providerName = match.Attributes.GetValueOrDefault(AttrProvider);
- if (providerName is not null)
+ ///
+ public Task SelectCredentialAsync(string accountId)
{
- var selected = ReadSelection(providerName);
- if (string.Equals(selected, accountId, StringComparison.OrdinalIgnoreCase))
+ if (!IsValidAccountId(accountId))
{
- ClearSelection(providerName);
+ return Task.FromResult(false);
}
- }
- return Task.FromResult(true);
- }
+ var match = SearchItems(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [AttrApp] = _appIdentifier,
+ [AttrKind] = KindCredential,
+ [AttrAccount] = accountId,
+ },
+ loadSecrets: false).FirstOrDefault();
- ///
- public Task SelectCredentialAsync(string accountId)
- {
- if (!IsValidAccountId(accountId)) return Task.FromResult(false);
+ if (match is null)
+ {
+ return Task.FromResult(false);
+ }
- var match = SearchItems(
- new Dictionary(StringComparer.Ordinal)
+ var providerName = match.Attributes.GetValueOrDefault(AttrProvider);
+ if (providerName is null)
{
- [AttrApp] = _appIdentifier,
- [AttrKind] = KindCredential,
- [AttrAccount] = accountId,
- },
- loadSecrets: false).FirstOrDefault();
+ return Task.FromResult(false);
+ }
- if (match is null) return Task.FromResult(false);
+ WriteSelection(providerName, accountId);
+ return Task.FromResult(true);
+ }
- var providerName = match.Attributes.GetValueOrDefault(AttrProvider);
- if (providerName is null) return Task.FromResult(false);
+ ///
+ public Task GetSelectedCredentialAsync(string providerName)
+ {
+ ValidateProviderName(providerName);
+ var selectedId = ReadSelection(providerName);
+ if (selectedId is null)
+ {
+ return Task.FromResult(null);
+ }
- WriteSelection(providerName, accountId);
- return Task.FromResult(true);
- }
+ return Task.FromResult(LookupCredentialByAccountId(providerName, selectedId));
+ }
- ///
- public Task GetSelectedCredentialAsync(string providerName)
- {
- ValidateProviderName(providerName);
- var selectedId = ReadSelection(providerName);
- if (selectedId is null) return Task.FromResult(null);
+ ///
+ public Task GetCredentialByIdAsync(string providerName, string accountId)
+ {
+ ValidateProviderName(providerName);
+ ValidateAccountId(accountId);
- return Task.FromResult(LookupCredentialByAccountId(providerName, selectedId));
- }
+ return Task.FromResult(LookupCredentialByAccountId(providerName, accountId));
+ }
- ///
- public Task GetCredentialByIdAsync(string providerName, string accountId)
- {
- ValidateProviderName(providerName);
- ValidateAccountId(accountId);
+ ///
+ /// Secret Service lookup keyed on (app, kind=credential, provider,
+ /// account). Shared by
+ /// and — neither modifies the
+ /// selection record.
+ ///
+ private string? LookupCredentialByAccountId(string providerName, string accountId)
+ {
+ return LookupPassword(new Dictionary(StringComparer.Ordinal)
+ {
+ [AttrApp] = _appIdentifier,
+ [AttrKind] = KindCredential,
+ [AttrProvider] = providerName,
+ [AttrAccount] = accountId,
+ });
+ }
- return Task.FromResult(LookupCredentialByAccountId(providerName, accountId));
- }
+ ///
+ public Task> GetProviderNamesAsync()
+ {
+ var items = SearchItems(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [AttrApp] = _appIdentifier,
+ [AttrKind] = KindCredential,
+ },
+ loadSecrets: false);
+
+ var names = items
+ .Select(i => i.Attributes.GetValueOrDefault(AttrProvider))
+ .Where(n => !string.IsNullOrEmpty(n))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToList();
+
+ return Task.FromResult>(names!);
+ }
- ///
- /// Secret Service lookup keyed on (app, kind=credential, provider,
- /// account). Shared by
- /// and — neither modifies the
- /// selection record.
- ///
- private string? LookupCredentialByAccountId(string providerName, string accountId)
- {
- return LookupPassword(new Dictionary(StringComparer.Ordinal)
+ ///
+ public Task> ExportCredentialsAsync()
{
- [AttrApp] = _appIdentifier,
- [AttrKind] = KindCredential,
- [AttrProvider] = providerName,
- [AttrAccount] = accountId,
- });
- }
+ var items = SearchItems(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [AttrApp] = _appIdentifier,
+ [AttrKind] = KindCredential,
+ },
+ loadSecrets: true);
- ///
- public Task> GetProviderNamesAsync()
- {
- var items = SearchItems(
- new Dictionary(StringComparer.Ordinal)
+ var selectionCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ var exports = new List();
+ foreach (var item in items)
{
- [AttrApp] = _appIdentifier,
- [AttrKind] = KindCredential,
- },
- loadSecrets: false);
+ var providerName = item.Attributes.GetValueOrDefault(AttrProvider);
+ if (string.IsNullOrEmpty(providerName))
+ {
+ continue;
+ }
+
+ if (!selectionCache.TryGetValue(providerName, out var selectedId))
+ {
+ selectedId = ReadSelection(providerName);
+ selectionCache[providerName] = selectedId;
+ }
- var names = items
- .Select(i => i.Attributes.GetValueOrDefault(AttrProvider))
- .Where(n => !string.IsNullOrEmpty(n))
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .OrderBy(n => n, StringComparer.Ordinal)
- .ToList();
+ var accountId = item.Attributes.GetValueOrDefault(AttrAccount, string.Empty);
- return Task.FromResult>(names!);
- }
+ exports.Add(new CredentialExport
+ {
+ AccountId = accountId,
+ AccountName = item.Attributes.GetValueOrDefault(AttrLabel, string.Empty),
+ ProviderName = providerName,
+ Environment = item.Attributes.GetValueOrDefault(AttrEnvironment, string.Empty),
+ CredentialData = item.Secret ?? string.Empty,
+ CreatedAt = ParseCreatedAt(item.Attributes.GetValueOrDefault(AttrCreatedAt)),
+ IsSelected = selectedId is not null && string.Equals(selectedId, accountId, StringComparison.OrdinalIgnoreCase),
+ });
+ }
- ///
- public Task> ExportCredentialsAsync()
- {
- var items = SearchItems(
- new Dictionary(StringComparer.Ordinal)
+ return Task.FromResult>(exports);
+ }
+
+ ///
+ public Task RestoreCredentialAsync(CredentialExport credential)
+ {
+ ArgumentNullException.ThrowIfNull(credential);
+ ValidateProviderName(credential.ProviderName);
+ ValidateAccountId(credential.AccountId);
+
+ // store overwrites an item whose attributes match exactly, so writing
+ // with the same (app, kind, provider, account) replaces any existing
+ // entry — a re-import is idempotent. CreatedAt is carried across
+ // faithfully via the attribute.
+ var attrs = new Dictionary(StringComparer.Ordinal)
{
[AttrApp] = _appIdentifier,
[AttrKind] = KindCredential,
- },
- loadSecrets: true);
+ [AttrProvider] = credential.ProviderName,
+ [AttrAccount] = credential.AccountId,
+ [AttrLabel] = credential.AccountName,
+ [AttrEnvironment] = credential.Environment,
+ [AttrCreatedAt] = credential.CreatedAt.ToString("O"),
+ };
+ var label = $"{_appIdentifier}: {credential.ProviderName}/{credential.AccountName}";
- var selectionCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- var exports = new List();
- foreach (var item in items)
- {
- var providerName = item.Attributes.GetValueOrDefault(AttrProvider);
- if (string.IsNullOrEmpty(providerName))
- continue;
+ StoreItem(attrs, label, credential.CredentialData);
- if (!selectionCache.TryGetValue(providerName, out var selectedId))
+ if (credential.IsSelected)
{
- selectedId = ReadSelection(providerName);
- selectionCache[providerName] = selectedId;
+ WriteSelection(credential.ProviderName, credential.AccountId);
}
- var accountId = item.Attributes.GetValueOrDefault(AttrAccount, string.Empty);
+ return Task.CompletedTask;
+ }
+
+ // =========================
+ // Internal helpers
+ // =========================
- exports.Add(new CredentialExport
+ private string? ReadSelection(string providerName)
+ {
+ return LookupPassword(new Dictionary(StringComparer.Ordinal)
{
- AccountId = accountId,
- AccountName = item.Attributes.GetValueOrDefault(AttrLabel, string.Empty),
- ProviderName = providerName,
- Environment = item.Attributes.GetValueOrDefault(AttrEnvironment, string.Empty),
- CredentialData = item.Secret ?? string.Empty,
- CreatedAt = ParseCreatedAt(item.Attributes.GetValueOrDefault(AttrCreatedAt)),
- IsSelected = selectedId is not null && string.Equals(selectedId, accountId, StringComparison.OrdinalIgnoreCase),
+ [AttrApp] = _appIdentifier,
+ [AttrKind] = KindSelection,
+ [AttrProvider] = providerName,
});
}
- return Task.FromResult>(exports);
- }
-
- ///
- public Task RestoreCredentialAsync(CredentialExport credential)
- {
- ArgumentNullException.ThrowIfNull(credential);
- ValidateProviderName(credential.ProviderName);
- ValidateAccountId(credential.AccountId);
-
- // store overwrites an item whose attributes match exactly, so writing
- // with the same (app, kind, provider, account) replaces any existing
- // entry — a re-import is idempotent. CreatedAt is carried across
- // faithfully via the attribute.
- var attrs = new Dictionary(StringComparer.Ordinal)
+ private void WriteSelection(string providerName, string accountId)
{
- [AttrApp] = _appIdentifier,
- [AttrKind] = KindCredential,
- [AttrProvider] = credential.ProviderName,
- [AttrAccount] = credential.AccountId,
- [AttrLabel] = credential.AccountName,
- [AttrEnvironment] = credential.Environment,
- [AttrCreatedAt] = credential.CreatedAt.ToString("O"),
- };
- var label = $"{_appIdentifier}: {credential.ProviderName}/{credential.AccountName}";
-
- StoreItem(attrs, label, credential.CredentialData);
-
- if (credential.IsSelected)
- {
- WriteSelection(credential.ProviderName, credential.AccountId);
+ // store overwrites an existing item with matching attributes, so
+ // we don't need a separate add-or-update dance.
+ StoreItem(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [AttrApp] = _appIdentifier,
+ [AttrKind] = KindSelection,
+ [AttrProvider] = providerName,
+ },
+ label: $"{_appIdentifier}: active {providerName}",
+ password: accountId);
}
- return Task.CompletedTask;
- }
-
- // =========================
- // Internal helpers
- // =========================
-
- private string? ReadSelection(string providerName)
- {
- return LookupPassword(new Dictionary(StringComparer.Ordinal)
+ private void ClearSelection(string providerName)
{
- [AttrApp] = _appIdentifier,
- [AttrKind] = KindSelection,
- [AttrProvider] = providerName,
- });
- }
-
- private void WriteSelection(string providerName, string accountId)
- {
- // store overwrites an existing item with matching attributes, so
- // we don't need a separate add-or-update dance.
- StoreItem(
- new Dictionary(StringComparer.Ordinal)
+ ClearItem(new Dictionary(StringComparer.Ordinal)
{
[AttrApp] = _appIdentifier,
[AttrKind] = KindSelection,
[AttrProvider] = providerName,
- },
- label: $"{_appIdentifier}: active {providerName}",
- password: accountId);
- }
+ });
+ }
- private void ClearSelection(string providerName)
- {
- ClearItem(new Dictionary(StringComparer.Ordinal)
+ private static DateTime ParseCreatedAt(string? value)
{
- [AttrApp] = _appIdentifier,
- [AttrKind] = KindSelection,
- [AttrProvider] = providerName,
- });
- }
-
- private static DateTime ParseCreatedAt(string? value)
- {
- if (string.IsNullOrEmpty(value)) return DateTime.MinValue;
- return DateTime.TryParse(
- value,
- System.Globalization.CultureInfo.InvariantCulture,
- System.Globalization.DateTimeStyles.RoundtripKind,
- out var parsed)
- ? parsed
- : DateTime.MinValue;
- }
+ if (string.IsNullOrEmpty(value))
+ {
+ return DateTime.MinValue;
+ }
- private static void ValidateProviderName(string providerName)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(providerName);
- if (providerName.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '.' && c != '_' && c != '-'))
- {
- throw new ArgumentException(
- $"Provider name '{providerName}' contains invalid characters. Allowed: ASCII letters, digits, '.', '_', '-'.",
- nameof(providerName));
+ return DateTime.TryParse(
+ value,
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.RoundtripKind,
+ out var parsed)
+ ? parsed
+ : DateTime.MinValue;
}
- }
-
- 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 _))
+ private static void ValidateProviderName(string providerName)
{
- throw new ArgumentException(
- $"Account id '{accountId}' is not a valid GUID.",
- nameof(accountId));
+ ArgumentException.ThrowIfNullOrWhiteSpace(providerName);
+ if (providerName.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '.' && c != '_' && c != '-'))
+ {
+ throw new ArgumentException(
+ $"Provider name '{providerName}' contains invalid characters. Allowed: ASCII letters, digits, '.', '_', '-'.",
+ nameof(providerName));
+ }
}
- }
-
- // =========================
- // Secret Service operations — each takes ownership of every GHashTable
- // / GError handle it creates and releases them via try/finally.
- // =========================
- private sealed class StoredItem
- {
- public required Dictionary Attributes { get; init; }
- public string? Secret { get; init; }
- }
+ private static bool IsValidAccountId(string? accountId) =>
+ !string.IsNullOrWhiteSpace(accountId) && Guid.TryParse(accountId, out _);
- private void StoreItem(Dictionary attributes, string label, string password)
- {
- var attrs = NewAttributes(attributes);
- try
+ private static void ValidateAccountId(string accountId)
{
- var status = secret_password_storev_sync(
- IntPtr.Zero,
- attrs,
- _collection,
- label,
- password,
- IntPtr.Zero,
- out var error);
- ThrowIfGError(error, "secret_password_storev_sync");
- if (status == 0)
+ ArgumentException.ThrowIfNullOrWhiteSpace(accountId);
+ if (!Guid.TryParse(accountId, out _))
{
- throw new InvalidOperationException("secret_password_storev_sync returned FALSE without a GError — the Secret Service may not be available.");
+ throw new ArgumentException(
+ $"Account id '{accountId}' is not a valid GUID.",
+ nameof(accountId));
}
}
- finally
+
+ // =========================
+ // Secret Service operations — each takes ownership of every GHashTable
+ // / GError handle it creates and releases them via try/finally.
+ // =========================
+
+ private sealed class StoredItem
{
- g_hash_table_unref(attrs);
+ public required Dictionary Attributes { get; init; }
+ public string? Secret { get; init; }
}
- }
- private static string? LookupPassword(Dictionary attributes)
- {
- var attrs = NewAttributes(attributes);
- try
+ private void StoreItem(Dictionary attributes, string label, string password)
{
- var result = secret_password_lookupv_sync(IntPtr.Zero, attrs, IntPtr.Zero, out var error);
- ThrowIfGError(error, "secret_password_lookupv_sync");
- if (result == IntPtr.Zero) return null;
+ var attrs = NewAttributes(attributes);
try
{
- return ReadUtf8(result);
+ var status = secret_password_storev_sync(
+ IntPtr.Zero,
+ attrs,
+ _collection,
+ label,
+ password,
+ IntPtr.Zero,
+ out var error);
+ ThrowIfGError(error, "secret_password_storev_sync");
+ if (status == 0)
+ {
+ throw new InvalidOperationException("secret_password_storev_sync returned FALSE without a GError — the Secret Service may not be available.");
+ }
}
finally
{
- secret_password_free(result);
+ g_hash_table_unref(attrs);
}
}
- finally
- {
- g_hash_table_unref(attrs);
- }
- }
- private static void ClearItem(Dictionary attributes)
- {
- var attrs = NewAttributes(attributes);
- try
+ private static string? LookupPassword(Dictionary attributes)
{
- _ = secret_password_clearv_sync(IntPtr.Zero, attrs, IntPtr.Zero, out var error);
- ThrowIfGError(error, "secret_password_clearv_sync");
+ var attrs = NewAttributes(attributes);
+ try
+ {
+ var result = secret_password_lookupv_sync(IntPtr.Zero, attrs, IntPtr.Zero, out var error);
+ ThrowIfGError(error, "secret_password_lookupv_sync");
+ if (result == IntPtr.Zero)
+ {
+ return null;
+ }
+
+ try
+ {
+ return ReadUtf8(result);
+ }
+ finally
+ {
+ secret_password_free(result);
+ }
+ }
+ finally
+ {
+ g_hash_table_unref(attrs);
+ }
}
- finally
+
+ private static void ClearItem(Dictionary attributes)
{
- g_hash_table_unref(attrs);
+ var attrs = NewAttributes(attributes);
+ try
+ {
+ _ = secret_password_clearv_sync(IntPtr.Zero, attrs, IntPtr.Zero, out var error);
+ ThrowIfGError(error, "secret_password_clearv_sync");
+ }
+ finally
+ {
+ g_hash_table_unref(attrs);
+ }
}
- }
- private static List SearchItems(Dictionary attributes, bool loadSecrets)
- {
- var attrs = NewAttributes(attributes);
- var flags = SecretSearchAll | (loadSecrets ? SecretSearchLoadSecrets | SecretSearchUnlock : 0);
- try
+ private static List SearchItems(Dictionary attributes, bool loadSecrets)
{
- var listPtr = secret_password_searchv_sync(IntPtr.Zero, attrs, flags, IntPtr.Zero, out var error);
- ThrowIfGError(error, "secret_password_searchv_sync");
-
- var results = new List();
- if (listPtr == IntPtr.Zero) return results;
-
+ var attrs = NewAttributes(attributes);
+ var flags = SecretSearchAll | (loadSecrets ? SecretSearchLoadSecrets | SecretSearchUnlock : 0);
try
{
- var count = g_list_length(listPtr);
- for (uint i = 0; i < count; i++)
- {
- var retrievable = g_list_nth_data(listPtr, i);
- if (retrievable == IntPtr.Zero) continue;
+ var listPtr = secret_password_searchv_sync(IntPtr.Zero, attrs, flags, IntPtr.Zero, out var error);
+ ThrowIfGError(error, "secret_password_searchv_sync");
- var itemAttrs = secret_retrievable_get_attributes(retrievable);
- var managedAttrs = itemAttrs == IntPtr.Zero ? [] : ReadAttributes(itemAttrs);
- if (itemAttrs != IntPtr.Zero) g_hash_table_unref(itemAttrs);
+ var results = new List();
+ if (listPtr == IntPtr.Zero)
+ {
+ return results;
+ }
- string? secret = null;
- if (loadSecrets)
+ try
+ {
+ var count = g_list_length(listPtr);
+ for (uint i = 0; i < count; i++)
{
- var secretValue = secret_retrievable_retrieve_secret_sync(retrievable, IntPtr.Zero, out var secretError);
- ThrowIfGError(secretError, "secret_retrievable_retrieve_secret_sync");
- try
+ var retrievable = g_list_nth_data(listPtr, i);
+ if (retrievable == IntPtr.Zero)
+ {
+ continue;
+ }
+
+ var itemAttrs = secret_retrievable_get_attributes(retrievable);
+ var managedAttrs = itemAttrs == IntPtr.Zero ? [] : ReadAttributes(itemAttrs);
+ if (itemAttrs != IntPtr.Zero)
{
- secret = ReadSecretValueAsString(secretValue);
+ g_hash_table_unref(itemAttrs);
}
- finally
+
+ string? secret = null;
+ if (loadSecrets)
{
- if (secretValue != IntPtr.Zero) secret_value_unref(secretValue);
+ var secretValue = secret_retrievable_retrieve_secret_sync(retrievable, IntPtr.Zero, out var secretError);
+ ThrowIfGError(secretError, "secret_retrievable_retrieve_secret_sync");
+ try
+ {
+ secret = ReadSecretValueAsString(secretValue);
+ }
+ finally
+ {
+ if (secretValue != IntPtr.Zero)
+ {
+ secret_value_unref(secretValue);
+ }
+ }
}
- }
- results.Add(new StoredItem
- {
- Attributes = managedAttrs,
- Secret = secret,
- });
+ results.Add(new StoredItem
+ {
+ Attributes = managedAttrs,
+ Secret = secret,
+ });
- // Each GList node owns a reference to its data; releasing
- // the data item itself is the caller's job.
- g_object_unref(retrievable);
+ // Each GList node owns a reference to its data; releasing
+ // the data item itself is the caller's job.
+ g_object_unref(retrievable);
+ }
+ }
+ finally
+ {
+ g_list_free(listPtr);
}
+ return results;
}
finally
{
- g_list_free(listPtr);
+ g_hash_table_unref(attrs);
}
- return results;
- }
- finally
- {
- g_hash_table_unref(attrs);
}
}
}
diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretInterop.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretInterop.cs
index 8cab3d7..34c0ec0 100644
--- a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretInterop.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretInterop.cs
@@ -1,332 +1,360 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
-namespace NextIteration.SpectreConsole.Auth.Persistence.Libsecret;
-
-///
-/// P/Invoke surface for libsecret-1 (the Secret Service client) plus the
-/// slice of GLib (libglib-2.0) needed to build GHashTables of
-/// attributes. Marked Linux-only because libsecret isn't a first-class
-/// dependency on macOS or Windows — this backend is gated to Linux hosts
-/// where a Secret Service daemon (GNOME Keyring, KWallet's shim, etc.) is
-/// running.
-///
-[SupportedOSPlatform("linux")]
-internal static partial class LibsecretInterop
+namespace NextIteration.SpectreConsole.Auth.Persistence.Libsecret
{
- private const string Libsecret = "libsecret-1.so.0";
- private const string Libglib = "libglib-2.0.so.0";
- private const string Libgobject = "libgobject-2.0.so.0";
+ ///
+ /// P/Invoke surface for libsecret-1 (the Secret Service client) plus the
+ /// slice of GLib (libglib-2.0) needed to build GHashTables of
+ /// attributes. Marked Linux-only because libsecret isn't a first-class
+ /// dependency on macOS or Windows — this backend is gated to Linux hosts
+ /// where a Secret Service daemon (GNOME Keyring, KWallet's shim, etc.) is
+ /// running.
+ ///
+ [SupportedOSPlatform("linux")]
+ internal static partial class LibsecretInterop
+ {
+ private const string Libsecret = "libsecret-1.so.0";
+ private const string Libglib = "libglib-2.0.so.0";
+ private const string Libgobject = "libgobject-2.0.so.0";
- // =========================
- // GLib — GHashTable
- // =========================
+ // =========================
+ // GLib — GHashTable
+ // =========================
- [LibraryImport(Libglib)]
- internal static partial IntPtr g_hash_table_new(IntPtr hashFunc, IntPtr keyEqualFunc);
+ [LibraryImport(Libglib)]
+ internal static partial IntPtr g_hash_table_new(IntPtr hashFunc, IntPtr keyEqualFunc);
- [LibraryImport(Libglib)]
- internal static partial IntPtr g_hash_table_new_full(
- IntPtr hashFunc, IntPtr keyEqualFunc, IntPtr keyDestroyFunc, IntPtr valueDestroyFunc);
+ [LibraryImport(Libglib)]
+ internal static partial IntPtr g_hash_table_new_full(
+ IntPtr hashFunc, IntPtr keyEqualFunc, IntPtr keyDestroyFunc, IntPtr valueDestroyFunc);
- [LibraryImport(Libglib)]
- [return: MarshalAs(UnmanagedType.I4)]
- internal static partial int g_hash_table_insert(IntPtr hashTable, IntPtr key, IntPtr value);
+ [LibraryImport(Libglib)]
+ [return: MarshalAs(UnmanagedType.I4)]
+ internal static partial int g_hash_table_insert(IntPtr hashTable, IntPtr key, IntPtr value);
- [LibraryImport(Libglib)]
- internal static partial void g_hash_table_unref(IntPtr hashTable);
+ [LibraryImport(Libglib)]
+ internal static partial void g_hash_table_unref(IntPtr hashTable);
- [LibraryImport(Libglib)]
- internal static partial IntPtr g_hash_table_lookup(IntPtr hashTable, IntPtr key);
+ [LibraryImport(Libglib)]
+ internal static partial IntPtr g_hash_table_lookup(IntPtr hashTable, IntPtr key);
- [LibraryImport(Libglib)]
- internal static partial uint g_hash_table_size(IntPtr hashTable);
+ [LibraryImport(Libglib)]
+ internal static partial uint g_hash_table_size(IntPtr hashTable);
- // GLib string utilities for converting managed strings into GLib-managed
- // C strings (since g_hash_table will own the memory of its keys/values
- // when we use a destroy function, we need strings allocated by g_malloc
- // so g_free can release them).
+ // GLib string utilities for converting managed strings into GLib-managed
+ // C strings (since g_hash_table will own the memory of its keys/values
+ // when we use a destroy function, we need strings allocated by g_malloc
+ // so g_free can release them).
- [LibraryImport(Libglib, StringMarshalling = StringMarshalling.Utf8)]
- internal static partial IntPtr g_strdup(string str);
+ [LibraryImport(Libglib, StringMarshalling = StringMarshalling.Utf8)]
+ internal static partial IntPtr g_strdup(string str);
- [LibraryImport(Libglib)]
- internal static partial void g_free(IntPtr mem);
+ [LibraryImport(Libglib)]
+ internal static partial void g_free(IntPtr mem);
- // =========================
- // GLib — GList (result of secret_service_search_sync)
- // =========================
+ // =========================
+ // GLib — GList (result of secret_service_search_sync)
+ // =========================
- [LibraryImport(Libglib)]
- internal static partial uint g_list_length(IntPtr list);
+ [LibraryImport(Libglib)]
+ internal static partial uint g_list_length(IntPtr list);
- [LibraryImport(Libglib)]
- internal static partial IntPtr g_list_nth_data(IntPtr list, uint n);
+ [LibraryImport(Libglib)]
+ internal static partial IntPtr g_list_nth_data(IntPtr list, uint n);
- [LibraryImport(Libglib)]
- internal static partial void g_list_free(IntPtr list);
+ [LibraryImport(Libglib)]
+ internal static partial void g_list_free(IntPtr list);
- // =========================
- // GLib — GError
- // =========================
+ // =========================
+ // GLib — GError
+ // =========================
- [StructLayout(LayoutKind.Sequential)]
- internal struct GError
- {
- public uint Domain;
- public int Code;
- public IntPtr Message; // UTF-8 C string owned by GError.
- }
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct GError
+ {
+ public uint Domain;
+ public int Code;
+ public IntPtr Message; // UTF-8 C string owned by GError.
+ }
- [LibraryImport(Libglib)]
- internal static partial void g_error_free(IntPtr error);
-
- // =========================
- // GObject — reference counting
- // =========================
-
- [LibraryImport(Libgobject)]
- internal static partial void g_object_unref(IntPtr obj);
-
- // =========================
- // libsecret
- // =========================
-
- // Search flags — bitfield. 1 = all matches, 2 = unlock automatically, 4 = include secrets in results.
- internal const int SecretSearchAll = 1 << 1; // SECRET_SEARCH_ALL
- internal const int SecretSearchUnlock = 1 << 2; // SECRET_SEARCH_UNLOCK
- internal const int SecretSearchLoadSecrets = 1 << 3; // SECRET_SEARCH_LOAD_SECRETS
-
- // Collection selector — magic strings as macros in the C headers; the
- // default-collection alias resolves to "default".
- internal const string SecretCollectionDefault = "default";
- internal const string SecretCollectionSession = "session";
-
- // Schema flag: SECRET_SCHEMA_NONE (0) — no attribute type checking.
- internal const int SecretSchemaNone = 0;
-
- // secret_schema_new(name, flags, attr_name, attr_type, …, NULL) — varargs.
- // We use the symbol-constant-with-a-custom-schema approach rather than
- // varargs: pass a schema that accepts any string attributes.
- //
- // The simpler path is to pass a schema with a single well-known layout
- // OR pass NULL schema and let libsecret default to SECRET_SCHEMA_COMPAT.
- // Since we need precise control over our attribute names (our own
- // namespace keys, not libsecret's defaults), we build a schema at startup.
-
- // For simplicity, we use the functions that accept a GHashTable of
- // attributes and a nullable SecretSchema pointer. Passing IntPtr.Zero
- // for the schema makes libsecret treat attributes opaquely.
-
- [LibraryImport(Libsecret, StringMarshalling = StringMarshalling.Utf8)]
- [return: MarshalAs(UnmanagedType.I4)]
- internal static partial int secret_password_storev_sync(
- IntPtr schema, // SecretSchema* — IntPtr.Zero means "no schema"
- IntPtr attributes, // GHashTable* of string → string
- string? collection, // collection alias or NULL
- string label,
- string password,
- IntPtr cancellable, // GCancellable* — IntPtr.Zero
- out IntPtr error);
-
- [LibraryImport(Libsecret, StringMarshalling = StringMarshalling.Utf8)]
- internal static partial IntPtr secret_password_lookupv_sync(
- IntPtr schema,
- IntPtr attributes,
- IntPtr cancellable,
- out IntPtr error);
-
- // Free a password string returned by secret_password_lookupv_sync — not
- // g_free, because libsecret may have placed it in non-pageable memory.
- [LibraryImport(Libsecret)]
- internal static partial void secret_password_free(IntPtr password);
-
- [LibraryImport(Libsecret)]
- [return: MarshalAs(UnmanagedType.I4)]
- internal static partial int secret_password_clearv_sync(
- IntPtr schema,
- IntPtr attributes,
- IntPtr cancellable,
- out IntPtr error);
-
- // secret_password_searchv_sync returns GList. Each
- // retrievable exposes attributes + secret via:
- [LibraryImport(Libsecret)]
- internal static partial IntPtr secret_password_searchv_sync(
- IntPtr schema,
- IntPtr attributes,
- int flags, // SECRET_SEARCH_* bitmask
- IntPtr cancellable,
- out IntPtr error);
-
- // SecretRetrievable (actually SecretItem when loaded with ALL flag) —
- // accessors.
- [LibraryImport(Libsecret)]
- internal static partial IntPtr secret_retrievable_get_attributes(IntPtr retrievable);
-
- [LibraryImport(Libsecret)]
- internal static partial IntPtr secret_retrievable_retrieve_secret_sync(
- IntPtr retrievable,
- IntPtr cancellable,
- out IntPtr error);
-
- [LibraryImport(Libsecret)]
- internal static partial IntPtr secret_value_get(IntPtr value, out UIntPtr length);
-
- [LibraryImport(Libsecret)]
- internal static partial void secret_value_unref(IntPtr value);
-
- [LibraryImport(Libsecret, StringMarshalling = StringMarshalling.Utf8)]
- internal static partial IntPtr secret_retrievable_get_label(IntPtr retrievable);
-
- // =========================
- // Helpers — managed over the raw P/Invoke
- // =========================
+ [LibraryImport(Libglib)]
+ internal static partial void g_error_free(IntPtr error);
+
+ // =========================
+ // GObject — reference counting
+ // =========================
+
+ [LibraryImport(Libgobject)]
+ internal static partial void g_object_unref(IntPtr obj);
+
+ // =========================
+ // libsecret
+ // =========================
+
+ // Search flags — bitfield. 1 = all matches, 2 = unlock automatically, 4 = include secrets in results.
+ internal const int SecretSearchAll = 1 << 1; // SECRET_SEARCH_ALL
+ internal const int SecretSearchUnlock = 1 << 2; // SECRET_SEARCH_UNLOCK
+ internal const int SecretSearchLoadSecrets = 1 << 3; // SECRET_SEARCH_LOAD_SECRETS
+
+ // Collection selector — magic strings as macros in the C headers; the
+ // default-collection alias resolves to "default".
+ internal const string SecretCollectionDefault = "default";
+ internal const string SecretCollectionSession = "session";
+
+ // Schema flag: SECRET_SCHEMA_NONE (0) — no attribute type checking.
+ internal const int SecretSchemaNone = 0;
+
+ // secret_schema_new(name, flags, attr_name, attr_type, …, NULL) — varargs.
+ // We use the symbol-constant-with-a-custom-schema approach rather than
+ // varargs: pass a schema that accepts any string attributes.
+ //
+ // The simpler path is to pass a schema with a single well-known layout
+ // OR pass NULL schema and let libsecret default to SECRET_SCHEMA_COMPAT.
+ // Since we need precise control over our attribute names (our own
+ // namespace keys, not libsecret's defaults), we build a schema at startup.
+
+ // For simplicity, we use the functions that accept a GHashTable of
+ // attributes and a nullable SecretSchema pointer. Passing IntPtr.Zero
+ // for the schema makes libsecret treat attributes opaquely.
+
+ [LibraryImport(Libsecret, StringMarshalling = StringMarshalling.Utf8)]
+ [return: MarshalAs(UnmanagedType.I4)]
+ internal static partial int secret_password_storev_sync(
+ IntPtr schema, // SecretSchema* — IntPtr.Zero means "no schema"
+ IntPtr attributes, // GHashTable* of string → string
+ string? collection, // collection alias or NULL
+ string label,
+ string password,
+ IntPtr cancellable, // GCancellable* — IntPtr.Zero
+ out IntPtr error);
+
+ [LibraryImport(Libsecret, StringMarshalling = StringMarshalling.Utf8)]
+ internal static partial IntPtr secret_password_lookupv_sync(
+ IntPtr schema,
+ IntPtr attributes,
+ IntPtr cancellable,
+ out IntPtr error);
+
+ // Free a password string returned by secret_password_lookupv_sync — not
+ // g_free, because libsecret may have placed it in non-pageable memory.
+ [LibraryImport(Libsecret)]
+ internal static partial void secret_password_free(IntPtr password);
+
+ [LibraryImport(Libsecret)]
+ [return: MarshalAs(UnmanagedType.I4)]
+ internal static partial int secret_password_clearv_sync(
+ IntPtr schema,
+ IntPtr attributes,
+ IntPtr cancellable,
+ out IntPtr error);
+
+ // secret_password_searchv_sync returns GList. Each
+ // retrievable exposes attributes + secret via:
+ [LibraryImport(Libsecret)]
+ internal static partial IntPtr secret_password_searchv_sync(
+ IntPtr schema,
+ IntPtr attributes,
+ int flags, // SECRET_SEARCH_* bitmask
+ IntPtr cancellable,
+ out IntPtr error);
+
+ // SecretRetrievable (actually SecretItem when loaded with ALL flag) —
+ // accessors.
+ [LibraryImport(Libsecret)]
+ internal static partial IntPtr secret_retrievable_get_attributes(IntPtr retrievable);
+
+ [LibraryImport(Libsecret)]
+ internal static partial IntPtr secret_retrievable_retrieve_secret_sync(
+ IntPtr retrievable,
+ IntPtr cancellable,
+ out IntPtr error);
+
+ [LibraryImport(Libsecret)]
+ internal static partial IntPtr secret_value_get(IntPtr value, out UIntPtr length);
+
+ [LibraryImport(Libsecret)]
+ internal static partial void secret_value_unref(IntPtr value);
+
+ [LibraryImport(Libsecret, StringMarshalling = StringMarshalling.Utf8)]
+ internal static partial IntPtr secret_retrievable_get_label(IntPtr retrievable);
+
+ // =========================
+ // Helpers — managed over the raw P/Invoke
+ // =========================
+
+ ///
+ /// Builds a GHashTable<string,string> from the supplied pairs.
+ /// Strings are duplicated into GLib-allocated memory so the destroy
+ /// functions (g_free) can release them correctly.
+ ///
+ internal static IntPtr NewAttributes(IReadOnlyDictionary pairs)
+ {
+ // Hash/equal funcs for string keys.
+ var hashFunc = ResolveExport(Libglib, "g_str_hash");
+ var equalFunc = ResolveExport(Libglib, "g_str_equal");
+ var freeFunc = ResolveExport(Libglib, "g_free");
- ///
- /// Builds a GHashTable<string,string> from the supplied pairs.
- /// Strings are duplicated into GLib-allocated memory so the destroy
- /// functions (g_free) can release them correctly.
- ///
- internal static IntPtr NewAttributes(IReadOnlyDictionary pairs)
- {
- // Hash/equal funcs for string keys.
- var hashFunc = ResolveExport(Libglib, "g_str_hash");
- var equalFunc = ResolveExport(Libglib, "g_str_equal");
- var freeFunc = ResolveExport(Libglib, "g_free");
+ var table = g_hash_table_new_full(hashFunc, equalFunc, freeFunc, freeFunc);
+ if (table == IntPtr.Zero)
+ {
+ throw new InvalidOperationException("g_hash_table_new_full failed.");
+ }
- var table = g_hash_table_new_full(hashFunc, equalFunc, freeFunc, freeFunc);
- if (table == IntPtr.Zero)
- throw new InvalidOperationException("g_hash_table_new_full failed.");
+ foreach (var (k, v) in pairs)
+ {
+ var keyPtr = g_strdup(k);
+ var valPtr = g_strdup(v);
+ _ = g_hash_table_insert(table, keyPtr, valPtr);
+ }
+ return table;
+ }
- foreach (var (k, v) in pairs)
+ ///
+ /// Reads a UTF-8 null-terminated C string at the given pointer without
+ /// taking ownership. Returns null for IntPtr.Zero.
+ ///
+ internal static string? ReadUtf8(IntPtr ptr) => ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
+
+ ///
+ /// Reads the attributes GHashTable from a SecretRetrievable into a
+ /// managed dictionary. The returned table is owned by libsecret; we
+ /// must call g_hash_table_unref when done.
+ ///
+ internal static Dictionary ReadAttributes(IntPtr hashTable)
{
- var keyPtr = g_strdup(k);
- var valPtr = g_strdup(v);
- _ = g_hash_table_insert(table, keyPtr, valPtr);
- }
- return table;
- }
+ if (hashTable == IntPtr.Zero)
+ {
+ return [];
+ }
- ///
- /// Reads a UTF-8 null-terminated C string at the given pointer without
- /// taking ownership. Returns null for IntPtr.Zero.
- ///
- internal static string? ReadUtf8(IntPtr ptr) => ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
+ // GLib provides g_hash_table_iter_init / g_hash_table_iter_next but
+ // the simpler path for our needs is to iterate known keys. Callers
+ // pass a list of attribute names they expect and we look them up.
+ // For generic enumeration we'd bind the iterator funcs — worth it
+ // since our list operation needs to read all attributes.
+ var result = new Dictionary(StringComparer.Ordinal);
+ var size = g_hash_table_size(hashTable);
+ if (size == 0)
+ {
+ return result;
+ }
- ///
- /// Reads the attributes GHashTable from a SecretRetrievable into a
- /// managed dictionary. The returned table is owned by libsecret; we
- /// must call g_hash_table_unref when done.
- ///
- internal static Dictionary ReadAttributes(IntPtr hashTable)
- {
- if (hashTable == IntPtr.Zero) return [];
-
- // GLib provides g_hash_table_iter_init / g_hash_table_iter_next but
- // the simpler path for our needs is to iterate known keys. Callers
- // pass a list of attribute names they expect and we look them up.
- // For generic enumeration we'd bind the iterator funcs — worth it
- // since our list operation needs to read all attributes.
- var result = new Dictionary(StringComparer.Ordinal);
- var size = g_hash_table_size(hashTable);
- if (size == 0) return result;
-
- // Bind iter API lazily.
- var iterInit = ResolveExport(Libglib, "g_hash_table_iter_init");
- var iterNext = ResolveExport(Libglib, "g_hash_table_iter_next");
-
- // GHashTableIter is an opaque struct; callers stack-allocate it.
- // sizeof(GHashTableIter) is 4 gpointers + 1 gint (~40 bytes on
- // 64-bit). We allocate generously to be safe across glib versions.
- var iterBuffer = Marshal.AllocHGlobal(128);
- try
- {
- var initDelegate = Marshal.GetDelegateForFunctionPointer(iterInit);
- initDelegate(iterBuffer, hashTable);
+ // Bind iter API lazily.
+ var iterInit = ResolveExport(Libglib, "g_hash_table_iter_init");
+ var iterNext = ResolveExport(Libglib, "g_hash_table_iter_next");
- var nextDelegate = Marshal.GetDelegateForFunctionPointer(iterNext);
- while (true)
+ // GHashTableIter is an opaque struct; callers stack-allocate it.
+ // sizeof(GHashTableIter) is 4 gpointers + 1 gint (~40 bytes on
+ // 64-bit). We allocate generously to be safe across glib versions.
+ var iterBuffer = Marshal.AllocHGlobal(128);
+ try
{
- if (!nextDelegate(iterBuffer, out var keyPtr, out var valPtr)) break;
- var key = ReadUtf8(keyPtr);
- var val = ReadUtf8(valPtr);
- if (key is not null && val is not null)
+ var initDelegate = Marshal.GetDelegateForFunctionPointer(iterInit);
+ initDelegate(iterBuffer, hashTable);
+
+ var nextDelegate = Marshal.GetDelegateForFunctionPointer(iterNext);
+ while (true)
{
- result[key] = val;
+ if (!nextDelegate(iterBuffer, out var keyPtr, out var valPtr))
+ {
+ break;
+ }
+
+ var key = ReadUtf8(keyPtr);
+ var val = ReadUtf8(valPtr);
+ if (key is not null && val is not null)
+ {
+ result[key] = val;
+ }
}
}
+ finally
+ {
+ Marshal.FreeHGlobal(iterBuffer);
+ }
+ return result;
}
- finally
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void GHashTableIterInit(IntPtr iter, IntPtr hashTable);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private delegate bool GHashTableIterNext(IntPtr iter, out IntPtr key, out IntPtr value);
+
+ ///
+ /// Reads a SecretValue into a managed byte array. Assumes UTF-8 text
+ /// (our credential payloads are JSON).
+ ///
+ internal static string ReadSecretValueAsString(IntPtr secretValue)
{
- Marshal.FreeHGlobal(iterBuffer);
- }
- return result;
- }
+ if (secretValue == IntPtr.Zero)
+ {
+ return string.Empty;
+ }
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate void GHashTableIterInit(IntPtr iter, IntPtr hashTable);
+ var ptr = secret_value_get(secretValue, out var length);
+ if (ptr == IntPtr.Zero)
+ {
+ return string.Empty;
+ }
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private delegate bool GHashTableIterNext(IntPtr iter, out IntPtr key, out IntPtr value);
+ var bytes = new byte[(int)length];
+ Marshal.Copy(ptr, bytes, 0, bytes.Length);
+ return System.Text.Encoding.UTF8.GetString(bytes);
+ }
- ///
- /// Reads a SecretValue into a managed byte array. Assumes UTF-8 text
- /// (our credential payloads are JSON).
- ///
- internal static string ReadSecretValueAsString(IntPtr secretValue)
- {
- if (secretValue == IntPtr.Zero) return string.Empty;
- var ptr = secret_value_get(secretValue, out var length);
- if (ptr == IntPtr.Zero) return string.Empty;
- var bytes = new byte[(int)length];
- Marshal.Copy(ptr, bytes, 0, bytes.Length);
- return System.Text.Encoding.UTF8.GetString(bytes);
- }
+ ///
+ /// Throws if
+ /// is non-zero, reading the message and freeing the GError before
+ /// returning control. The message is copied into the exception, so the
+ /// caller retains no pointer into the freed memory.
+ ///
+ internal static void ThrowIfGError(IntPtr error, string operation)
+ {
+ if (error == IntPtr.Zero)
+ {
+ return;
+ }
- ///
- /// Throws if
- /// is non-zero, reading the message and freeing the GError before
- /// returning control. The message is copied into the exception, so the
- /// caller retains no pointer into the freed memory.
- ///
- internal static void ThrowIfGError(IntPtr error, string operation)
- {
- if (error == IntPtr.Zero) return;
- var errStruct = Marshal.PtrToStructure(error);
- var message = ReadUtf8(errStruct.Message) ?? "(no message)";
- g_error_free(error);
- throw new InvalidOperationException($"{operation} failed: {message}");
- }
+ var errStruct = Marshal.PtrToStructure(error);
+ var message = ReadUtf8(errStruct.Message) ?? "(no message)";
+ g_error_free(error);
+ throw new InvalidOperationException($"{operation} failed: {message}");
+ }
- // =========================
- // Dynamic symbol resolution — caches library handles for the duration
- // of the process. GLib's function-pointer constants aren't plain
- // CFString/kSec-style data symbols; they're function addresses, which
- // we need to pass as IntPtr to g_hash_table_new_full.
- // =========================
+ // =========================
+ // Dynamic symbol resolution — caches library handles for the duration
+ // of the process. GLib's function-pointer constants aren't plain
+ // CFString/kSec-style data symbols; they're function addresses, which
+ // we need to pass as IntPtr to g_hash_table_new_full.
+ // =========================
- private static readonly Dictionary _libraryHandles = [];
- private static readonly Dictionary _symbolCache = [];
- private static readonly object _resolveLock = new();
+ private static readonly Dictionary _libraryHandles = [];
+ private static readonly Dictionary _symbolCache = [];
+ private static readonly object _resolveLock = new();
- private static IntPtr ResolveExport(string library, string symbolName)
- {
- lock (_resolveLock)
+ private static IntPtr ResolveExport(string library, string symbolName)
{
- var cacheKey = $"{library}!{symbolName}";
- if (_symbolCache.TryGetValue(cacheKey, out var cached)) return cached;
-
- if (!_libraryHandles.TryGetValue(library, out var handle))
+ lock (_resolveLock)
{
- handle = NativeLibrary.Load(library);
- _libraryHandles[library] = handle;
- }
+ var cacheKey = $"{library}!{symbolName}";
+ if (_symbolCache.TryGetValue(cacheKey, out var cached))
+ {
+ return cached;
+ }
- var addr = NativeLibrary.GetExport(handle, symbolName);
- _symbolCache[cacheKey] = addr;
- return addr;
+ if (!_libraryHandles.TryGetValue(library, out var handle))
+ {
+ handle = NativeLibrary.Load(library);
+ _libraryHandles[library] = handle;
+ }
+
+ var addr = NativeLibrary.GetExport(handle, symbolName);
+ _symbolCache[cacheKey] = addr;
+ return addr;
+ }
}
}
}
diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/SelectionsLock.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/SelectionsLock.cs
index 852633f..673c19a 100644
--- a/src/NextIteration.SpectreConsole.Auth/Persistence/SelectionsLock.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Persistence/SelectionsLock.cs
@@ -29,7 +29,10 @@ internal sealed class SelectionsLock : IDisposable
private readonly FileStream _stream;
- private SelectionsLock(FileStream stream) => _stream = stream;
+ private SelectionsLock(FileStream stream)
+ {
+ _stream = stream;
+ }
internal static async Task AcquireAsync(string lockPath, CancellationToken ct = default)
{
diff --git a/src/NextIteration.SpectreConsole.Auth/Portability/CredentialArchive.cs b/src/NextIteration.SpectreConsole.Auth/Portability/CredentialArchive.cs
index bbea7bc..e0fdedd 100644
--- a/src/NextIteration.SpectreConsole.Auth/Portability/CredentialArchive.cs
+++ b/src/NextIteration.SpectreConsole.Auth/Portability/CredentialArchive.cs
@@ -48,7 +48,7 @@ internal static string Serialize(IReadOnlyList credentials, st
var payload = new ArchivePayload
{
ExportedAtUtc = DateTime.UtcNow,
- Credentials = credentials.Select(ArchiveCredential.From).ToList(),
+ Credentials = [.. credentials.Select(ArchiveCredential.From)],
};
var plaintext = JsonSerializer.SerializeToUtf8Bytes(payload, _jsonOptions);
@@ -152,7 +152,7 @@ internal static IReadOnlyList Deserialize(string bundle, strin
{
var payload = JsonSerializer.Deserialize(plaintext, _jsonOptions);
var credentials = payload?.Credentials ?? [];
- return credentials.Select(c => c.ToExport()).ToList();
+ return [.. credentials.Select(c => c.ToExport())];
}
catch (JsonException ex)
{
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CommandFormattingTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CommandFormattingTests.cs
index 378cd74..a019999 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CommandFormattingTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CommandFormattingTests.cs
@@ -2,39 +2,32 @@
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Commands;
-
-public sealed class CommandFormattingTests
+namespace NextIteration.SpectreConsole.Auth.Tests.Commands
{
- [Fact]
- public void ShortId_FullGuid_ReturnsFirstEightPlusEllipsis()
+ public sealed class CommandFormattingTests
{
- var id = "12345678-1234-1234-1234-123456789012";
- Assert.Equal("12345678...", CommandFormatting.ShortId(id));
- }
+ [Fact]
+ public void ShortId_FullGuid_ReturnsFirstEightPlusEllipsis()
+ {
+ var id = "12345678-1234-1234-1234-123456789012";
+ Assert.Equal("12345678...", CommandFormatting.ShortId(id));
+ }
- [Theory]
- [InlineData("abc")] // shorter than 8 — no slice attempted
- [InlineData("1234567")] // exactly one short
- public void ShortId_ShortString_ReturnsFullStringWithoutThrowing(string id)
- {
- // Regression: previously every site sliced AccountId[..8] which
- // throws ArgumentOutOfRangeException for short user-supplied ids
- // (e.g. `accounts delete abc`).
- Assert.Equal(id, CommandFormatting.ShortId(id));
- }
+ [Theory]
+ [InlineData("abc")] // shorter than 8 — no slice attempted
+ [InlineData("1234567")] // exactly one short
+ // Regression: previously every site sliced AccountId[..8] which throws
+ // ArgumentOutOfRangeException for short user-supplied ids (e.g.
+ // `accounts delete abc`).
+ public void ShortId_ShortString_ReturnsFullStringWithoutThrowing(string id) =>
+ Assert.Equal(id, CommandFormatting.ShortId(id));
- [Fact]
- public void ShortId_ExactlyEight_ReturnsValuePlusEllipsis()
- {
- Assert.Equal("12345678...", CommandFormatting.ShortId("12345678"));
- }
+ [Fact]
+ public void ShortId_ExactlyEight_ReturnsValuePlusEllipsis() => Assert.Equal("12345678...", CommandFormatting.ShortId("12345678"));
- [Theory]
- [InlineData(null)]
- [InlineData("")]
- public void ShortId_NullOrEmpty_ReturnsEmpty(string? id)
- {
- Assert.Equal(string.Empty, CommandFormatting.ShortId(id));
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void ShortId_NullOrEmpty_ReturnsEmpty(string? id) => Assert.Equal(string.Empty, CommandFormatting.ShortId(id));
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Encryption/LocalFileCredentialEncryptionTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Encryption/LocalFileCredentialEncryptionTests.cs
index 7e3e99c..7f4f688 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Encryption/LocalFileCredentialEncryptionTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Encryption/LocalFileCredentialEncryptionTests.cs
@@ -6,452 +6,453 @@
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Encryption;
-
-public sealed class LocalFileCredentialEncryptionTests
+namespace NextIteration.SpectreConsole.Auth.Tests.Encryption
{
- [Fact]
- public async Task RoundTrip_Text_ReturnsOriginal()
+ public sealed class LocalFileCredentialEncryptionTests
{
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
-
- var cipher = await encryption.EncryptAsync("hello, world!");
- var plain = await encryption.DecryptAsync(cipher);
-
- Assert.Equal("hello, world!", plain);
- }
+ [Fact]
+ public async Task RoundTrip_Text_ReturnsOriginal()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- [Fact]
- public async Task RoundTrip_JsonPayload_ReturnsOriginal()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ var cipher = await encryption.EncryptAsync("hello, world!");
+ var plain = await encryption.DecryptAsync(cipher);
- var payload = """{"apiKey":"secret-value","baseUrl":"https://example.com/"}""";
- var cipher = await encryption.EncryptAsync(payload);
- var plain = await encryption.DecryptAsync(cipher);
+ Assert.Equal("hello, world!", plain);
+ }
- Assert.Equal(payload, plain);
- }
+ [Fact]
+ public async Task RoundTrip_JsonPayload_ReturnsOriginal()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- [Fact]
- public async Task RoundTrip_UnicodeContent_ReturnsOriginal()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ var payload = """{"apiKey":"secret-value","baseUrl":"https://example.com/"}""";
+ var cipher = await encryption.EncryptAsync(payload);
+ var plain = await encryption.DecryptAsync(cipher);
- var payload = "café — 日本語 — 🔐";
- var cipher = await encryption.EncryptAsync(payload);
- var plain = await encryption.DecryptAsync(cipher);
+ Assert.Equal(payload, plain);
+ }
- Assert.Equal(payload, plain);
- }
+ [Fact]
+ public async Task RoundTrip_UnicodeContent_ReturnsOriginal()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- [Fact]
- public async Task EncryptAsync_EmptyString_ReturnsEmpty()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ var payload = "café — 日本語 — 🔐";
+ var cipher = await encryption.EncryptAsync(payload);
+ var plain = await encryption.DecryptAsync(cipher);
- var cipher = await encryption.EncryptAsync("");
+ Assert.Equal(payload, plain);
+ }
- Assert.Equal("", cipher);
- }
+ [Fact]
+ public async Task EncryptAsync_EmptyString_ReturnsEmpty()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- [Fact]
- public async Task DecryptAsync_EmptyString_ReturnsEmpty()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ var cipher = await encryption.EncryptAsync("");
- var plain = await encryption.DecryptAsync("");
+ Assert.Equal("", cipher);
+ }
- Assert.Equal("", plain);
- }
+ [Fact]
+ public async Task DecryptAsync_EmptyString_ReturnsEmpty()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- [Fact]
- public async Task DecryptAsync_InvalidBase64_Throws()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ var plain = await encryption.DecryptAsync("");
- var ex = await Assert.ThrowsAsync(
- () => encryption.DecryptAsync("this is not base64!!"));
- Assert.Contains("base64", ex.Message, StringComparison.OrdinalIgnoreCase);
- }
+ Assert.Equal("", plain);
+ }
- [Fact]
- public async Task DecryptAsync_TamperedCiphertext_ThrowsIntegrityError()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ [Fact]
+ public async Task DecryptAsync_InvalidBase64_Throws()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- var cipher = await encryption.EncryptAsync("secret message");
- var bytes = Convert.FromBase64String(cipher);
+ var ex = await Assert.ThrowsAsync(
+ () => encryption.DecryptAsync("this is not base64!!"));
+ Assert.Contains("base64", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
- // Flip a byte inside the ciphertext portion (after the 12-byte nonce
- // and 16-byte tag header).
- bytes[^1] ^= 0xFF;
- var tampered = Convert.ToBase64String(bytes);
+ [Fact]
+ public async Task DecryptAsync_TamperedCiphertext_ThrowsIntegrityError()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- var ex = await Assert.ThrowsAsync(
- () => encryption.DecryptAsync(tampered));
- Assert.Contains("integrity", ex.Message, StringComparison.OrdinalIgnoreCase);
- Assert.IsType(ex.InnerException);
- }
+ var cipher = await encryption.EncryptAsync("secret message");
+ var bytes = Convert.FromBase64String(cipher);
- [Fact]
- public async Task DecryptAsync_TamperedTag_ThrowsIntegrityError()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ // Flip a byte inside the ciphertext portion (after the 12-byte nonce
+ // and 16-byte tag header).
+ bytes[^1] ^= 0xFF;
+ var tampered = Convert.ToBase64String(bytes);
- var cipher = await encryption.EncryptAsync("secret message");
- var bytes = Convert.FromBase64String(cipher);
+ var ex = await Assert.ThrowsAsync(
+ () => encryption.DecryptAsync(tampered));
+ Assert.Contains("integrity", ex.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.IsType(ex.InnerException);
+ }
- // Flip a byte inside the 16-byte GCM tag (immediately after the nonce).
- bytes[12] ^= 0x01;
- var tampered = Convert.ToBase64String(bytes);
+ [Fact]
+ public async Task DecryptAsync_TamperedTag_ThrowsIntegrityError()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- await Assert.ThrowsAsync(
- () => encryption.DecryptAsync(tampered));
- }
+ var cipher = await encryption.EncryptAsync("secret message");
+ var bytes = Convert.FromBase64String(cipher);
- [Fact]
- public async Task DecryptAsync_TamperedNonce_ThrowsIntegrityError()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ // Flip a byte inside the 16-byte GCM tag (immediately after the nonce).
+ bytes[12] ^= 0x01;
+ var tampered = Convert.ToBase64String(bytes);
- var cipher = await encryption.EncryptAsync("secret message");
- var bytes = Convert.FromBase64String(cipher);
+ await Assert.ThrowsAsync(
+ () => encryption.DecryptAsync(tampered));
+ }
- // Flip a byte inside the 12-byte nonce.
- bytes[0] ^= 0x01;
- var tampered = Convert.ToBase64String(bytes);
+ [Fact]
+ public async Task DecryptAsync_TamperedNonce_ThrowsIntegrityError()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- await Assert.ThrowsAsync(
- () => encryption.DecryptAsync(tampered));
- }
+ var cipher = await encryption.EncryptAsync("secret message");
+ var bytes = Convert.FromBase64String(cipher);
- [Fact]
- public async Task DecryptAsync_TruncatedPayload_ThrowsFormatError()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ // Flip a byte inside the 12-byte nonce.
+ bytes[0] ^= 0x01;
+ var tampered = Convert.ToBase64String(bytes);
- // "QUJD" = base64 of "ABC" — 3 bytes, way under the 28-byte GCM header.
- var ex = await Assert.ThrowsAsync(
- () => encryption.DecryptAsync("QUJD"));
- Assert.Contains("shorter", ex.Message, StringComparison.OrdinalIgnoreCase);
- }
+ await Assert.ThrowsAsync(
+ () => encryption.DecryptAsync(tampered));
+ }
- [Fact]
- public async Task EncryptAsync_SamePlaintext_ProducesDifferentCiphertextEachTime()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ [Fact]
+ public async Task DecryptAsync_TruncatedPayload_ThrowsFormatError()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- var a = await encryption.EncryptAsync("identical");
- var b = await encryption.EncryptAsync("identical");
+ // "QUJD" = base64 of "ABC" — 3 bytes, way under the 28-byte GCM header.
+ var ex = await Assert.ThrowsAsync(
+ () => encryption.DecryptAsync("QUJD"));
+ Assert.Contains("shorter", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
- Assert.NotEqual(a, b);
- }
+ [Fact]
+ public async Task EncryptAsync_SamePlaintext_ProducesDifferentCiphertextEachTime()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- [Fact]
- public async Task Encryption_Persists_AcrossInstances()
- {
- using var temp = new TempDir();
+ var a = await encryption.EncryptAsync("identical");
+ var b = await encryption.EncryptAsync("identical");
- string cipher;
- {
- var first = new LocalFileCredentialEncryption(temp.Path);
- cipher = await first.EncryptAsync("preserved across instance boundary");
+ Assert.NotEqual(a, b);
}
- var second = new LocalFileCredentialEncryption(temp.Path);
- var plain = await second.DecryptAsync(cipher);
+ [Fact]
+ public async Task Encryption_Persists_AcrossInstances()
+ {
+ using var temp = new TempDir();
- Assert.Equal("preserved across instance boundary", plain);
- }
+ string cipher;
+ {
+ var first = new LocalFileCredentialEncryption(temp.Path);
+ cipher = await first.EncryptAsync("preserved across instance boundary");
+ }
- [Fact]
- public async Task Keystore_IsCreated_OnFirstUse()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
- var keystorePath = Path.Join(temp.Path, ".keystore");
+ var second = new LocalFileCredentialEncryption(temp.Path);
+ var plain = await second.DecryptAsync(cipher);
- Assert.False(File.Exists(keystorePath), "keystore should not exist before first encrypt/decrypt");
+ Assert.Equal("preserved across instance boundary", plain);
+ }
- _ = await encryption.EncryptAsync("trigger keystore creation");
+ [Fact]
+ public async Task Keystore_IsCreated_OnFirstUse()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
+ var keystorePath = Path.Join(temp.Path, ".keystore");
- Assert.True(File.Exists(keystorePath), "keystore should be created on first encrypt");
- }
+ Assert.False(File.Exists(keystorePath), "keystore should not exist before first encrypt/decrypt");
- [Fact]
- public async Task Decrypt_WithDifferentKeystore_Throws()
- {
- using var tempA = new TempDir();
- using var tempB = new TempDir();
+ _ = await encryption.EncryptAsync("trigger keystore creation");
- var encryptionA = new LocalFileCredentialEncryption(tempA.Path);
- var cipherFromA = await encryptionA.EncryptAsync("bound to keystore A");
+ Assert.True(File.Exists(keystorePath), "keystore should be created on first encrypt");
+ }
- // Simulate an attacker copying the ciphertext but not the keystore.
- var encryptionB = new LocalFileCredentialEncryption(tempB.Path);
+ [Fact]
+ public async Task Decrypt_WithDifferentKeystore_Throws()
+ {
+ using var tempA = new TempDir();
+ using var tempB = new TempDir();
- await Assert.ThrowsAsync(
- () => encryptionB.DecryptAsync(cipherFromA));
- }
+ var encryptionA = new LocalFileCredentialEncryption(tempA.Path);
+ var cipherFromA = await encryptionA.EncryptAsync("bound to keystore A");
- [Fact]
- public void Constructor_NullDirectory_Throws()
- {
- // ArgumentException.ThrowIfNullOrWhiteSpace throws
- // ArgumentNullException on null input (a subclass of ArgumentException).
- Assert.ThrowsAny(
- () => new LocalFileCredentialEncryption(null!));
- }
+ // Simulate an attacker copying the ciphertext but not the keystore.
+ var encryptionB = new LocalFileCredentialEncryption(tempB.Path);
- [Fact]
- public void Constructor_EmptyDirectory_Throws()
- {
- Assert.Throws(
- () => new LocalFileCredentialEncryption(""));
- }
+ await Assert.ThrowsAsync(
+ () => encryptionB.DecryptAsync(cipherFromA));
+ }
- [Fact]
- public void Constructor_WhitespaceDirectory_Throws()
- {
- Assert.Throws(
- () => new LocalFileCredentialEncryption(" "));
- }
+ [Fact]
+ public void Constructor_NullDirectory_Throws()
+ {
+ // ArgumentException.ThrowIfNullOrWhiteSpace throws
+ // ArgumentNullException on null input (a subclass of ArgumentException).
+ Assert.ThrowsAny(
+ () => new LocalFileCredentialEncryption(null!));
+ }
- // =========================
- // Caller-supplied additional entropy
- // =========================
+ [Fact]
+ public void Constructor_EmptyDirectory_Throws()
+ {
+ Assert.Throws(
+ () => new LocalFileCredentialEncryption(""));
+ }
- [Fact]
- public async Task RoundTrip_WithCallerEntropy_ReturnsOriginal()
- {
- using var temp = new TempDir();
- var entropy = "secret-deployment-token"u8.ToArray();
- var encryption = new LocalFileCredentialEncryption(temp.Path, entropy);
+ [Fact]
+ public void Constructor_WhitespaceDirectory_Throws()
+ {
+ Assert.Throws(
+ () => new LocalFileCredentialEncryption(" "));
+ }
- var cipher = await encryption.EncryptAsync("hello with entropy");
- var plain = await encryption.DecryptAsync(cipher);
+ // =========================
+ // Caller-supplied additional entropy
+ // =========================
- Assert.Equal("hello with entropy", plain);
- }
+ [Fact]
+ public async Task RoundTrip_WithCallerEntropy_ReturnsOriginal()
+ {
+ using var temp = new TempDir();
+ var entropy = "secret-deployment-token"u8.ToArray();
+ var encryption = new LocalFileCredentialEncryption(temp.Path, entropy);
- [Fact]
- public async Task Decrypt_WithDifferentEntropy_Throws()
- {
- using var temp = new TempDir();
- var entropyA = "entropy-alpha"u8.ToArray();
- var encryption = new LocalFileCredentialEncryption(temp.Path, entropyA);
- var cipher = await encryption.EncryptAsync("bound to entropy alpha");
-
- // Delete the keystore and create a new instance with a different
- // entropy — the new instance will write a fresh keystore and fail
- // to decrypt the old ciphertext.
- File.Delete(Path.Join(temp.Path, ".keystore"));
- var entropyB = "entropy-bravo"u8.ToArray();
- var wrongEntropy = new LocalFileCredentialEncryption(temp.Path, entropyB);
-
- var ex = await Assert.ThrowsAsync(
- () => wrongEntropy.DecryptAsync(cipher));
- Assert.Contains("integrity check", ex.Message, StringComparison.OrdinalIgnoreCase);
- }
+ var cipher = await encryption.EncryptAsync("hello with entropy");
+ var plain = await encryption.DecryptAsync(cipher);
- [Fact]
- public async Task Decrypt_WithEntropyAgainstKeystoreWrittenWithout_ThrowsIntegrityError()
- {
- using var temp = new TempDir();
+ Assert.Equal("hello with entropy", plain);
+ }
- // Write a keystore with NO entropy, then try to read with entropy
- // set — the derived KEK differs, so loading the keystore fails.
- var noEntropy = new LocalFileCredentialEncryption(temp.Path);
- _ = await noEntropy.EncryptAsync("anything");
+ [Fact]
+ public async Task Decrypt_WithDifferentEntropy_Throws()
+ {
+ using var temp = new TempDir();
+ var entropyA = "entropy-alpha"u8.ToArray();
+ var encryption = new LocalFileCredentialEncryption(temp.Path, entropyA);
+ var cipher = await encryption.EncryptAsync("bound to entropy alpha");
+
+ // Delete the keystore and create a new instance with a different
+ // entropy — the new instance will write a fresh keystore and fail
+ // to decrypt the old ciphertext.
+ File.Delete(Path.Join(temp.Path, ".keystore"));
+ var entropyB = "entropy-bravo"u8.ToArray();
+ var wrongEntropy = new LocalFileCredentialEncryption(temp.Path, entropyB);
+
+ var ex = await Assert.ThrowsAsync(
+ () => wrongEntropy.DecryptAsync(cipher));
+ Assert.Contains("integrity check", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
- var withEntropy = new LocalFileCredentialEncryption(temp.Path, "new-entropy"u8.ToArray());
+ [Fact]
+ public async Task Decrypt_WithEntropyAgainstKeystoreWrittenWithout_ThrowsIntegrityError()
+ {
+ using var temp = new TempDir();
- // The very first call will try to load + decrypt the keystore using
- // the wrong KEK.
- await Assert.ThrowsAsync(
- () => withEntropy.EncryptAsync("this triggers keystore load"));
- }
+ // Write a keystore with NO entropy, then try to read with entropy
+ // set — the derived KEK differs, so loading the keystore fails.
+ var noEntropy = new LocalFileCredentialEncryption(temp.Path);
+ _ = await noEntropy.EncryptAsync("anything");
- [Fact]
- public async Task Encryption_IsBackwardCompatible_WhenEntropyNotSupplied()
- {
- // Regression guard: a keystore written by the pre-entropy code path
- // must remain readable when the caller upgrades to the new API but
- // doesn't pass entropy. Since we can't literally run the old code
- // here, this test proves the equivalent contract: omitting entropy
- // (null or empty) produces the same KEK as the pre-entropy default.
- using var temp = new TempDir();
-
- var withoutEntropy = new LocalFileCredentialEncryption(temp.Path);
- var cipher = await withoutEntropy.EncryptAsync("backward compat check");
-
- // New instance with explicit null — should read the existing keystore.
- var explicitNull = new LocalFileCredentialEncryption(temp.Path, null);
- Assert.Equal("backward compat check", await explicitNull.DecryptAsync(cipher));
-
- // Same for an empty array — treated as "no entropy."
- var emptyArray = new LocalFileCredentialEncryption(temp.Path, []);
- Assert.Equal("backward compat check", await emptyArray.DecryptAsync(cipher));
- }
+ var withEntropy = new LocalFileCredentialEncryption(temp.Path, "new-entropy"u8.ToArray());
- [Fact]
- public async Task Entropy_Persists_AcrossInstances()
- {
- using var temp = new TempDir();
- var entropy = "stable-deployment-secret"u8.ToArray();
+ // The very first call will try to load + decrypt the keystore using
+ // the wrong KEK.
+ await Assert.ThrowsAsync(
+ () => withEntropy.EncryptAsync("this triggers keystore load"));
+ }
- string cipher;
+ [Fact]
+ public async Task Encryption_IsBackwardCompatible_WhenEntropyNotSupplied()
{
- var first = new LocalFileCredentialEncryption(temp.Path, entropy);
- cipher = await first.EncryptAsync("survives across instances");
+ // Regression guard: a keystore written by the pre-entropy code path
+ // must remain readable when the caller upgrades to the new API but
+ // doesn't pass entropy. Since we can't literally run the old code
+ // here, this test proves the equivalent contract: omitting entropy
+ // (null or empty) produces the same KEK as the pre-entropy default.
+ using var temp = new TempDir();
+
+ var withoutEntropy = new LocalFileCredentialEncryption(temp.Path);
+ var cipher = await withoutEntropy.EncryptAsync("backward compat check");
+
+ // New instance with explicit null — should read the existing keystore.
+ var explicitNull = new LocalFileCredentialEncryption(temp.Path, null);
+ Assert.Equal("backward compat check", await explicitNull.DecryptAsync(cipher));
+
+ // Same for an empty array — treated as "no entropy."
+ var emptyArray = new LocalFileCredentialEncryption(temp.Path, []);
+ Assert.Equal("backward compat check", await emptyArray.DecryptAsync(cipher));
}
- var second = new LocalFileCredentialEncryption(temp.Path, entropy);
- Assert.Equal("survives across instances", await second.DecryptAsync(cipher));
- }
+ [Fact]
+ public async Task Entropy_Persists_AcrossInstances()
+ {
+ using var temp = new TempDir();
+ var entropy = "stable-deployment-secret"u8.ToArray();
- [Fact]
- public async Task Entropy_DefensivelyCopied_MutationAfterConstructIsIgnored()
- {
- using var temp = new TempDir();
- var entropy = new byte[] { 1, 2, 3, 4 };
- var encryption = new LocalFileCredentialEncryption(temp.Path, entropy);
+ string cipher;
+ {
+ var first = new LocalFileCredentialEncryption(temp.Path, entropy);
+ cipher = await first.EncryptAsync("survives across instances");
+ }
- var cipher = await encryption.EncryptAsync("immutable entropy");
+ var second = new LocalFileCredentialEncryption(temp.Path, entropy);
+ Assert.Equal("survives across instances", await second.DecryptAsync(cipher));
+ }
- // Mutate the caller's buffer — the stored copy must be unaffected.
- for (var i = 0; i < entropy.Length; i++)
+ [Fact]
+ public async Task Entropy_DefensivelyCopied_MutationAfterConstructIsIgnored()
{
- entropy[i] = 0;
- }
+ using var temp = new TempDir();
+ var entropy = new byte[] { 1, 2, 3, 4 };
+ var encryption = new LocalFileCredentialEncryption(temp.Path, entropy);
- Assert.Equal("immutable entropy", await encryption.DecryptAsync(cipher));
- }
+ var cipher = await encryption.EncryptAsync("immutable entropy");
- // =========================
- // Keystore format versioning
- // =========================
+ // Mutate the caller's buffer — the stored copy must be unaffected.
+ for (var i = 0; i < entropy.Length; i++)
+ {
+ entropy[i] = 0;
+ }
- private static readonly byte[] KeystoreMagic = "NISCA-KS"u8.ToArray();
+ Assert.Equal("immutable entropy", await encryption.DecryptAsync(cipher));
+ }
- [Fact]
- public async Task Keystore_WrittenByThisVersion_CarriesFormatHeader()
- {
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ // =========================
+ // Keystore format versioning
+ // =========================
- _ = await encryption.EncryptAsync("trigger keystore creation");
+ private static readonly byte[] KeystoreMagic = "NISCA-KS"u8.ToArray();
- var bytes = await File.ReadAllBytesAsync(Path.Join(temp.Path, ".keystore"), TestContext.Current.CancellationToken);
- Assert.True(bytes.Length > KeystoreMagic.Length + 1);
- Assert.Equal(KeystoreMagic, bytes[..KeystoreMagic.Length]);
- Assert.Equal(1, bytes[KeystoreMagic.Length]); // format version
- }
+ [Fact]
+ public async Task Keystore_WrittenByThisVersion_CarriesFormatHeader()
+ {
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
- [Fact]
- public async Task Keystore_LegacyHeaderless_IsStillReadable()
- {
- using var temp = new TempDir();
- var keystorePath = Path.Join(temp.Path, ".keystore");
+ _ = await encryption.EncryptAsync("trigger keystore creation");
- // Produce a keystore, then strip its header to reconstruct the legacy
- // headerless on-disk shape a pre-header library version would have
- // written. A fresh instance must still read ciphertext bound to it.
- var writer = new LocalFileCredentialEncryption(temp.Path);
- var cipher = await writer.EncryptAsync("bound to a legacy keystore");
+ var bytes = await File.ReadAllBytesAsync(Path.Join(temp.Path, ".keystore"), TestContext.Current.CancellationToken);
+ Assert.True(bytes.Length > KeystoreMagic.Length + 1);
+ Assert.Equal(KeystoreMagic, bytes[..KeystoreMagic.Length]);
+ Assert.Equal(1, bytes[KeystoreMagic.Length]); // format version
+ }
- var framed = await File.ReadAllBytesAsync(keystorePath, TestContext.Current.CancellationToken);
- var legacy = framed[(KeystoreMagic.Length + 1)..];
- await File.WriteAllBytesAsync(keystorePath, legacy, TestContext.Current.CancellationToken);
+ [Fact]
+ public async Task Keystore_LegacyHeaderless_IsStillReadable()
+ {
+ using var temp = new TempDir();
+ var keystorePath = Path.Join(temp.Path, ".keystore");
- var reader = new LocalFileCredentialEncryption(temp.Path);
- Assert.Equal("bound to a legacy keystore", await reader.DecryptAsync(cipher));
- }
+ // Produce a keystore, then strip its header to reconstruct the legacy
+ // headerless on-disk shape a pre-header library version would have
+ // written. A fresh instance must still read ciphertext bound to it.
+ var writer = new LocalFileCredentialEncryption(temp.Path);
+ var cipher = await writer.EncryptAsync("bound to a legacy keystore");
- [Fact]
- public async Task Keystore_UnknownFormatVersion_ThrowsClearError()
- {
- using var temp = new TempDir();
- var keystorePath = Path.Join(temp.Path, ".keystore");
+ var framed = await File.ReadAllBytesAsync(keystorePath, TestContext.Current.CancellationToken);
+ var legacy = framed[(KeystoreMagic.Length + 1)..];
+ await File.WriteAllBytesAsync(keystorePath, legacy, TestContext.Current.CancellationToken);
- var writer = new LocalFileCredentialEncryption(temp.Path);
- _ = await writer.EncryptAsync("anything");
+ var reader = new LocalFileCredentialEncryption(temp.Path);
+ Assert.Equal("bound to a legacy keystore", await reader.DecryptAsync(cipher));
+ }
- // Bump the version byte to one this build doesn't understand.
- var bytes = await File.ReadAllBytesAsync(keystorePath, TestContext.Current.CancellationToken);
- bytes[KeystoreMagic.Length] = 0xFF;
- await File.WriteAllBytesAsync(keystorePath, bytes, TestContext.Current.CancellationToken);
+ [Fact]
+ public async Task Keystore_UnknownFormatVersion_ThrowsClearError()
+ {
+ using var temp = new TempDir();
+ var keystorePath = Path.Join(temp.Path, ".keystore");
- var reader = new LocalFileCredentialEncryption(temp.Path);
- var ex = await Assert.ThrowsAsync(
- () => reader.EncryptAsync("triggers keystore load"));
- Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase);
- }
+ var writer = new LocalFileCredentialEncryption(temp.Path);
+ _ = await writer.EncryptAsync("anything");
- // =========================
- // Zero-on-dispose
- // =========================
+ // Bump the version byte to one this build doesn't understand.
+ var bytes = await File.ReadAllBytesAsync(keystorePath, TestContext.Current.CancellationToken);
+ bytes[KeystoreMagic.Length] = 0xFF;
+ await File.WriteAllBytesAsync(keystorePath, bytes, TestContext.Current.CancellationToken);
- [Fact]
- public async Task Dispose_ZeroesKeyMaterial_AndIsIdempotent()
- {
- using var temp = new TempDir();
- var entropy = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
- // `using` guarantees disposal even if EncryptAsync throws; the explicit
- // Dispose() calls below still exercise the zeroing + idempotency.
- using var encryption = new LocalFileCredentialEncryption(temp.Path, entropy);
-
- _ = await encryption.EncryptAsync("ensure the data key is derived");
-
- encryption.Dispose();
- encryption.Dispose(); // idempotent — must not throw
-
- // The internal defensive copy of the entropy is zeroed.
- var entropyField = typeof(LocalFileCredentialEncryption)
- .GetField("_callerEntropy", BindingFlags.NonPublic | BindingFlags.Instance)!;
- var storedEntropy = (byte[])entropyField.GetValue(encryption)!;
- Assert.All(storedEntropy, b => Assert.Equal(0, b));
-
- // The cached data key is zeroed too.
- var dataKeyField = typeof(LocalFileCredentialEncryption)
- .GetField("_dataKey", BindingFlags.NonPublic | BindingFlags.Instance)!;
- var lazy = (Lazy>)dataKeyField.GetValue(encryption)!;
- Assert.True(lazy.IsValueCreated);
- Assert.All(await lazy.Value, b => Assert.Equal(0, b));
- }
+ var reader = new LocalFileCredentialEncryption(temp.Path);
+ var ex = await Assert.ThrowsAsync(
+ () => reader.EncryptAsync("triggers keystore load"));
+ Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
- [Fact]
- public async Task ConcurrentDecrypt_WithSharedInstance_Succeeds()
- {
- // Lazy> should serialise the first derivation but let
- // subsequent calls complete in parallel once the key is cached.
- using var temp = new TempDir();
- var encryption = new LocalFileCredentialEncryption(temp.Path);
+ // =========================
+ // Zero-on-dispose
+ // =========================
- var ciphers = new List();
- for (var i = 0; i < 5; i++)
+ [Fact]
+ public async Task Dispose_ZeroesKeyMaterial_AndIsIdempotent()
{
- ciphers.Add(await encryption.EncryptAsync($"payload-{i}"));
+ using var temp = new TempDir();
+ var entropy = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
+ // `using` guarantees disposal even if EncryptAsync throws; the explicit
+ // Dispose() calls below still exercise the zeroing + idempotency.
+ using var encryption = new LocalFileCredentialEncryption(temp.Path, entropy);
+
+ _ = await encryption.EncryptAsync("ensure the data key is derived");
+
+ encryption.Dispose();
+ encryption.Dispose(); // idempotent — must not throw
+
+ // The internal defensive copy of the entropy is zeroed.
+ var entropyField = typeof(LocalFileCredentialEncryption)
+ .GetField("_callerEntropy", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ var storedEntropy = (byte[])entropyField.GetValue(encryption)!;
+ Assert.All(storedEntropy, b => Assert.Equal(0, b));
+
+ // The cached data key is zeroed too.
+ var dataKeyField = typeof(LocalFileCredentialEncryption)
+ .GetField("_dataKey", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ var lazy = (Lazy>)dataKeyField.GetValue(encryption)!;
+ Assert.True(lazy.IsValueCreated);
+ Assert.All(await lazy.Value, b => Assert.Equal(0, b));
}
- var tasks = ciphers.Select(c => encryption.DecryptAsync(c)).ToArray();
- var results = await Task.WhenAll(tasks);
-
- for (var i = 0; i < results.Length; i++)
+ [Fact]
+ public async Task ConcurrentDecrypt_WithSharedInstance_Succeeds()
{
- Assert.Equal($"payload-{i}", results[i]);
+ // Lazy> should serialise the first derivation but let
+ // subsequent calls complete in parallel once the key is cached.
+ using var temp = new TempDir();
+ var encryption = new LocalFileCredentialEncryption(temp.Path);
+
+ var ciphers = new List();
+ for (var i = 0; i < 5; i++)
+ {
+ ciphers.Add(await encryption.EncryptAsync($"payload-{i}"));
+ }
+
+ var tasks = ciphers.Select(c => encryption.DecryptAsync(c)).ToArray();
+ var results = await Task.WhenAll(tasks);
+
+ for (var i = 0; i < results.Length; i++)
+ {
+ Assert.Equal($"payload-{i}", results[i]);
+ }
}
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/RetryHelper.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/RetryHelper.cs
index bf72c6a..0228c1e 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/RetryHelper.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/RetryHelper.cs
@@ -1,66 +1,67 @@
-namespace NextIteration.SpectreConsole.Auth.Tests.Infrastructure;
-
-///
-/// Small polling helper for the OS-native secret-store tests. A just-completed
-/// AddCredentialAsync against the macOS Keychain or the Linux Secret
-/// Service is not always immediately visible to the next lookup when two test
-/// processes (multi-targeting) hit the same store concurrently — so an
-/// add-then-delete can see the delete's lookup miss the item and return false.
-/// That is a store-visibility timing artifact of the tests running side by
-/// side, not a defect in the manager, so it is closed here rather than by
-/// making the production delete path slower.
-///
-internal static class RetryHelper
+namespace NextIteration.SpectreConsole.Auth.Tests.Infrastructure
{
///
- /// Invokes until it returns
- /// or the attempts are exhausted, pausing between
- /// tries. Returns the last result — only if every
- /// attempt failed, so a genuinely-absent target still fails the assertion.
+ /// Small polling helper for the OS-native secret-store tests. A just-completed
+ /// AddCredentialAsync against the macOS Keychain or the Linux Secret
+ /// Service is not always immediately visible to the next lookup when two test
+ /// processes (multi-targeting) hit the same store concurrently — so an
+ /// add-then-delete can see the delete's lookup miss the item and return false.
+ /// That is a store-visibility timing artifact of the tests running side by
+ /// side, not a defect in the manager, so it is closed here rather than by
+ /// making the production delete path slower.
///
- internal static async Task UntilTrueAsync(
- Func> action,
- int maxAttempts = 20,
- int delayMs = 25)
+ internal static class RetryHelper
{
- for (var attempt = 1; attempt <= maxAttempts; attempt++)
+ ///
+ /// Invokes until it returns
+ /// or the attempts are exhausted, pausing between
+ /// tries. Returns the last result — only if every
+ /// attempt failed, so a genuinely-absent target still fails the assertion.
+ ///
+ internal static async Task UntilTrueAsync(
+ Func> action,
+ int maxAttempts = 20,
+ int delayMs = 25)
{
- if (await action())
+ for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
- return true;
+ if (await action())
+ {
+ return true;
+ }
+
+ if (attempt < maxAttempts)
+ {
+ await Task.Delay(delayMs);
+ }
}
- if (attempt < maxAttempts)
+ return false;
+ }
+
+ ///
+ /// Invokes until its result satisfies
+ /// or the attempts are exhausted, and returns
+ /// that result. Used for read-after-write against the OS secret stores,
+ /// where a just-completed add isn't always immediately visible to the next
+ /// query under concurrent (multi-targeted) test runs. Returns the last
+ /// result even if the predicate never held, so the caller's assertion still
+ /// reports the real failure rather than a timeout.
+ ///
+ internal static async Task UntilAsync(
+ Func> action,
+ Func predicate,
+ int maxAttempts = 20,
+ int delayMs = 25)
+ {
+ var result = await action();
+ for (var attempt = 1; !predicate(result) && attempt < maxAttempts; attempt++)
{
await Task.Delay(delayMs);
+ result = await action();
}
- }
- return false;
- }
-
- ///
- /// Invokes until its result satisfies
- /// or the attempts are exhausted, and returns
- /// that result. Used for read-after-write against the OS secret stores,
- /// where a just-completed add isn't always immediately visible to the next
- /// query under concurrent (multi-targeted) test runs. Returns the last
- /// result even if the predicate never held, so the caller's assertion still
- /// reports the real failure rather than a timeout.
- ///
- internal static async Task UntilAsync(
- Func> action,
- Func predicate,
- int maxAttempts = 20,
- int delayMs = 25)
- {
- var result = await action();
- for (var attempt = 1; !predicate(result) && attempt < maxAttempts; attempt++)
- {
- await Task.Delay(delayMs);
- result = await action();
+ return result;
}
-
- return result;
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/TempDir.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/TempDir.cs
index 26e9b0a..5f9cd99 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/TempDir.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/TempDir.cs
@@ -1,34 +1,35 @@
-namespace NextIteration.SpectreConsole.Auth.Tests.Infrastructure;
-
-///
-/// Throwaway directory under the system temp path. Tests that need a
-/// credentials directory (or any disk scratch space) should wrap this in
-/// using so the directory is recursively removed when the test ends
-/// regardless of pass/fail.
-///
-internal sealed class TempDir : IDisposable
+namespace NextIteration.SpectreConsole.Auth.Tests.Infrastructure
{
- public string Path { get; } =
- System.IO.Path.Join(System.IO.Path.GetTempPath(), "ni.sca.tests." + Guid.NewGuid().ToString("N"));
-
- public TempDir()
+ ///
+ /// Throwaway directory under the system temp path. Tests that need a
+ /// credentials directory (or any disk scratch space) should wrap this in
+ /// using so the directory is recursively removed when the test ends
+ /// regardless of pass/fail.
+ ///
+ internal sealed class TempDir : IDisposable
{
- Directory.CreateDirectory(Path);
- }
+ public string Path { get; } =
+ System.IO.Path.Join(System.IO.Path.GetTempPath(), "ni.sca.tests." + Guid.NewGuid().ToString("N"));
- public void Dispose()
- {
- try
+ public TempDir()
{
- if (Directory.Exists(Path))
- {
- Directory.Delete(Path, recursive: true);
- }
+ Directory.CreateDirectory(Path);
}
- catch
+
+ public void Dispose()
{
- // Best-effort cleanup. Stray scratch dirs in %TEMP% aren't a
- // problem — the OS cleans temp on reboot eventually.
+ try
+ {
+ if (Directory.Exists(Path))
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup. Stray scratch dirs in %TEMP% aren't a
+ // problem — the OS cleans temp on reboot eventually.
+ }
}
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/NextIteration.SpectreConsole.Auth.Tests.csproj b/tests/NextIteration.SpectreConsole.Auth.Tests/NextIteration.SpectreConsole.Auth.Tests.csproj
index 9193fd5..1fc1ef4 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/NextIteration.SpectreConsole.Auth.Tests.csproj
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/NextIteration.SpectreConsole.Auth.Tests.csproj
@@ -18,8 +18,16 @@
CA2007 (ConfigureAwait) doesn't apply in test contexts; there
is no SynchronizationContext to recapture.
+
+ IDE0005 (remove unnecessary usings) only runs at build when
+ GenerateDocumentationFile is true — which a test project sets to
+ false (above). With EnforceCodeStyleInBuild on (STANDARD.md 1.2.1),
+ the canonical .editorconfig gates IDE0005 as a warning, so Roslyn
+ would hard-error demanding the doc file be enabled. Suppress it here;
+ IDE0005 still gates the shipping project. STANDARD.md 2.7 needs the
+ matching amendment estate-wide.
-->
- $(NoWarn);CA1707;CA1515;CA2007
+ $(NoWarn);CA1707;CA1515;CA2007;IDE0005
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/AtomicFileTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/AtomicFileTests.cs
index 47d2ede..6856f6b 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/AtomicFileTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/AtomicFileTests.cs
@@ -3,129 +3,130 @@
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Persistence;
-
-public sealed class AtomicFileTests
+namespace NextIteration.SpectreConsole.Auth.Tests.Persistence
{
- [Fact]
- public async Task WriteAllTextAsync_WritesExpectedContent()
+ public sealed class AtomicFileTests
{
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "file.txt");
-
- await AtomicFile.WriteAllTextAsync(target, "hello");
+ [Fact]
+ public async Task WriteAllTextAsync_WritesExpectedContent()
+ {
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "file.txt");
- Assert.Equal("hello", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken));
- }
+ await AtomicFile.WriteAllTextAsync(target, "hello");
- [Fact]
- public async Task WriteAllBytesAsync_WritesExpectedBytes()
- {
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "file.bin");
- var payload = new byte[] { 0x00, 0x01, 0x02, 0xFE, 0xFF };
+ Assert.Equal("hello", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken));
+ }
- await AtomicFile.WriteAllBytesAsync(target, payload);
+ [Fact]
+ public async Task WriteAllBytesAsync_WritesExpectedBytes()
+ {
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "file.bin");
+ var payload = new byte[] { 0x00, 0x01, 0x02, 0xFE, 0xFF };
- Assert.Equal(payload, await File.ReadAllBytesAsync(target, TestContext.Current.CancellationToken));
- }
+ await AtomicFile.WriteAllBytesAsync(target, payload);
- [Fact]
- public async Task WriteAllTextAsync_NoTempFileLeftBehindAfterSuccess()
- {
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "file.txt");
+ Assert.Equal(payload, await File.ReadAllBytesAsync(target, TestContext.Current.CancellationToken));
+ }
- await AtomicFile.WriteAllTextAsync(target, "hello");
+ [Fact]
+ public async Task WriteAllTextAsync_NoTempFileLeftBehindAfterSuccess()
+ {
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "file.txt");
- // Only the final file should exist — no stray .tmp files.
- var files = Directory.GetFiles(temp.Path);
- var only = Assert.Single(files);
- Assert.Equal(target, only);
- }
+ await AtomicFile.WriteAllTextAsync(target, "hello");
- [Fact]
- public async Task WriteAllTextAsync_OverwritesExisting()
- {
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "file.txt");
- await File.WriteAllTextAsync(target, "original", TestContext.Current.CancellationToken);
+ // Only the final file should exist — no stray .tmp files.
+ var files = Directory.GetFiles(temp.Path);
+ var only = Assert.Single(files);
+ Assert.Equal(target, only);
+ }
- await AtomicFile.WriteAllTextAsync(target, "replaced");
+ [Fact]
+ public async Task WriteAllTextAsync_OverwritesExisting()
+ {
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "file.txt");
+ await File.WriteAllTextAsync(target, "original", TestContext.Current.CancellationToken);
- Assert.Equal("replaced", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken));
- }
+ await AtomicFile.WriteAllTextAsync(target, "replaced");
- [Fact]
- public async Task WriteAllTextAsync_DoesNotExposeIntermediateState()
- {
- // Between the moment the temp file is fully written and the rename,
- // a concurrent observer should see either the old content or the new
- // content — never an empty/half-written target.
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "file.txt");
- await File.WriteAllTextAsync(target, "original", TestContext.Current.CancellationToken);
-
- // Hard to probe the race deterministically, so at minimum assert
- // that the post-write state is fully the new content.
- await AtomicFile.WriteAllTextAsync(target, "replaced-content-that-is-longer");
-
- Assert.Equal("replaced-content-that-is-longer", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken));
- }
+ Assert.Equal("replaced", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken));
+ }
- [Fact]
- public async Task WriteAllTextAsync_SetsUnixMode_OnUnix()
- {
- if (OperatingSystem.IsWindows())
+ [Fact]
+ public async Task WriteAllTextAsync_DoesNotExposeIntermediateState()
{
- return; // Unix-only assertion; chmod is a no-op on Windows.
+ // Between the moment the temp file is fully written and the rename,
+ // a concurrent observer should see either the old content or the new
+ // content — never an empty/half-written target.
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "file.txt");
+ await File.WriteAllTextAsync(target, "original", TestContext.Current.CancellationToken);
+
+ // Hard to probe the race deterministically, so at minimum assert
+ // that the post-write state is fully the new content.
+ await AtomicFile.WriteAllTextAsync(target, "replaced-content-that-is-longer");
+
+ Assert.Equal("replaced-content-that-is-longer", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken));
}
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "file.txt");
-
- await AtomicFile.WriteAllTextAsync(
- target,
- "secret",
- UnixFileMode.UserRead | UnixFileMode.UserWrite);
-
- var mode = File.GetUnixFileMode(target);
- Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode);
- }
+ [Fact]
+ public async Task WriteAllTextAsync_SetsUnixMode_OnUnix()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return; // Unix-only assertion; chmod is a no-op on Windows.
+ }
- [Fact]
- public async Task WriteAllTextAsync_NullUnixMode_DoesNotThrow()
- {
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "file.txt");
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "file.txt");
- await AtomicFile.WriteAllTextAsync(target, "hello", unixMode: null);
+ await AtomicFile.WriteAllTextAsync(
+ target,
+ "secret",
+ UnixFileMode.UserRead | UnixFileMode.UserWrite);
- Assert.Equal("hello", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken));
- }
+ var mode = File.GetUnixFileMode(target);
+ Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode);
+ }
- [Fact]
- public async Task WriteAllTextAsync_UsesUniqueTempName_SafeForConcurrentWriters()
- {
- // Two simultaneous writers to the same target must not collide on
- // a shared {target}.tmp name. The helper's unique temp name +
- // last-rename-wins semantic means both succeed; only one final
- // content persists.
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "file.txt");
-
- var tasks = new[]
+ [Fact]
+ public async Task WriteAllTextAsync_NullUnixMode_DoesNotThrow()
{
- AtomicFile.WriteAllTextAsync(target, "writer-a"),
- AtomicFile.WriteAllTextAsync(target, "writer-b"),
- };
- await Task.WhenAll(tasks);
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "file.txt");
- var final = await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken);
- Assert.True(final is "writer-a" or "writer-b", $"expected one of the two writes to win, got: {final}");
+ await AtomicFile.WriteAllTextAsync(target, "hello", unixMode: null);
- // No stragglers.
- var files = Directory.GetFiles(temp.Path);
- Assert.Single(files);
+ Assert.Equal("hello", await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task WriteAllTextAsync_UsesUniqueTempName_SafeForConcurrentWriters()
+ {
+ // Two simultaneous writers to the same target must not collide on
+ // a shared {target}.tmp name. The helper's unique temp name +
+ // last-rename-wins semantic means both succeed; only one final
+ // content persists.
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "file.txt");
+
+ var tasks = new[]
+ {
+ AtomicFile.WriteAllTextAsync(target, "writer-a"),
+ AtomicFile.WriteAllTextAsync(target, "writer-b"),
+ };
+ await Task.WhenAll(tasks);
+
+ var final = await File.ReadAllTextAsync(target, TestContext.Current.CancellationToken);
+ Assert.True(final is "writer-a" or "writer-b", $"expected one of the two writes to win, got: {final}");
+
+ // No stragglers.
+ var files = Directory.GetFiles(temp.Path);
+ Assert.Single(files);
+ }
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/CredentialsDirectoryTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/CredentialsDirectoryTests.cs
index 79eb589..ce55d7e 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/CredentialsDirectoryTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/CredentialsDirectoryTests.cs
@@ -3,95 +3,96 @@
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Persistence;
-
-public sealed class CredentialsDirectoryTests
+namespace NextIteration.SpectreConsole.Auth.Tests.Persistence
{
- [Fact]
- public void Ensure_CreatesDirectory_WhenMissing()
+ public sealed class CredentialsDirectoryTests
{
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "creds");
- Assert.False(Directory.Exists(target));
+ [Fact]
+ public void Ensure_CreatesDirectory_WhenMissing()
+ {
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "creds");
+ Assert.False(Directory.Exists(target));
- CredentialsDirectory.Ensure(target);
+ CredentialsDirectory.Ensure(target);
- Assert.True(Directory.Exists(target));
- }
+ Assert.True(Directory.Exists(target));
+ }
- [Fact]
- public void Ensure_CreatesNestedDirectory_WhenParentMissing()
- {
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "nested", "creds");
- Assert.False(Directory.Exists(target));
+ [Fact]
+ public void Ensure_CreatesNestedDirectory_WhenParentMissing()
+ {
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "nested", "creds");
+ Assert.False(Directory.Exists(target));
- CredentialsDirectory.Ensure(target);
+ CredentialsDirectory.Ensure(target);
- Assert.True(Directory.Exists(target));
- }
+ Assert.True(Directory.Exists(target));
+ }
- [Fact]
- public void Ensure_NoOp_WhenDirectoryAlreadyExists()
- {
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "creds");
- Directory.CreateDirectory(target);
+ [Fact]
+ public void Ensure_NoOp_WhenDirectoryAlreadyExists()
+ {
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "creds");
+ Directory.CreateDirectory(target);
- // Touch a marker file inside so we can verify the directory isn't
- // recreated (which would wipe contents).
- var marker = Path.Join(target, "marker.txt");
- File.WriteAllText(marker, "hello");
+ // Touch a marker file inside so we can verify the directory isn't
+ // recreated (which would wipe contents).
+ var marker = Path.Join(target, "marker.txt");
+ File.WriteAllText(marker, "hello");
- CredentialsDirectory.Ensure(target);
+ CredentialsDirectory.Ensure(target);
- Assert.True(Directory.Exists(target));
- Assert.True(File.Exists(marker));
- Assert.Equal("hello", File.ReadAllText(marker));
- }
+ Assert.True(Directory.Exists(target));
+ Assert.True(File.Exists(marker));
+ Assert.Equal("hello", File.ReadAllText(marker));
+ }
- [Fact]
- public void Ensure_SetsUnixMode0700_OnFirstCreation()
- {
- if (OperatingSystem.IsWindows())
+ [Fact]
+ public void Ensure_SetsUnixMode0700_OnFirstCreation()
{
- return; // Unix-only: Windows uses ACLs, verified via the file-perm integration path.
- }
+ if (OperatingSystem.IsWindows())
+ {
+ return; // Unix-only: Windows uses ACLs, verified via the file-perm integration path.
+ }
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "creds");
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "creds");
- CredentialsDirectory.Ensure(target);
+ CredentialsDirectory.Ensure(target);
- var mode = File.GetUnixFileMode(target);
- Assert.Equal(
- UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
- mode);
- }
+ var mode = File.GetUnixFileMode(target);
+ Assert.Equal(
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
+ mode);
+ }
- [Fact]
- public void Ensure_DoesNotChange_ExistingUnixMode()
- {
- if (OperatingSystem.IsWindows())
+ [Fact]
+ public void Ensure_DoesNotChange_ExistingUnixMode()
{
- return;
+ if (OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ using var temp = new TempDir();
+ var target = Path.Join(temp.Path, "creds");
+ Directory.CreateDirectory(target);
+
+ // A deliberately-permissive mode that the library would never choose.
+ var originalMode = UnixFileMode.UserRead
+ | UnixFileMode.UserWrite
+ | UnixFileMode.UserExecute
+ | UnixFileMode.GroupRead
+ | UnixFileMode.GroupExecute;
+ File.SetUnixFileMode(target, originalMode);
+
+ CredentialsDirectory.Ensure(target);
+
+ // Should respect consumer-chosen perms on an existing directory.
+ Assert.Equal(originalMode, File.GetUnixFileMode(target));
}
-
- using var temp = new TempDir();
- var target = Path.Join(temp.Path, "creds");
- Directory.CreateDirectory(target);
-
- // A deliberately-permissive mode that the library would never choose.
- var originalMode = UnixFileMode.UserRead
- | UnixFileMode.UserWrite
- | UnixFileMode.UserExecute
- | UnixFileMode.GroupRead
- | UnixFileMode.GroupExecute;
- File.SetUnixFileMode(target, originalMode);
-
- CredentialsDirectory.Ensure(target);
-
- // Should respect consumer-chosen perms on an existing directory.
- Assert.Equal(originalMode, File.GetUnixFileMode(target));
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/FileCredentialManagerTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/FileCredentialManagerTests.cs
index 921bbec..b51df04 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/FileCredentialManagerTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/FileCredentialManagerTests.cs
@@ -5,612 +5,617 @@
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Persistence;
-
-public sealed class FileCredentialManagerTests
+namespace NextIteration.SpectreConsole.Auth.Tests.Persistence
{
- private static FileCredentialManager CreateManager(string directory, IEnumerable? summaryProviders = null)
+ public sealed class FileCredentialManagerTests
{
- var encryption = new LocalFileCredentialEncryption(directory);
- return new FileCredentialManager(encryption, directory, summaryProviders);
- }
-
- [Fact]
- public void Constructor_NullDirectory_Throws()
- {
- // ArgumentException.ThrowIfNullOrWhiteSpace throws
- // ArgumentNullException on null input (a subclass of ArgumentException).
- var encryption = new LocalFileCredentialEncryption(Path.GetTempPath());
- Assert.ThrowsAny(
- () => new FileCredentialManager(encryption, null!));
- }
+ private static FileCredentialManager CreateManager(string directory, IEnumerable? summaryProviders = null)
+ {
+ var encryption = new LocalFileCredentialEncryption(directory);
+ return new FileCredentialManager(encryption, directory, summaryProviders);
+ }
- [Fact]
- public void Constructor_EmptyDirectory_Throws()
- {
- var encryption = new LocalFileCredentialEncryption(Path.GetTempPath());
- Assert.Throws(
- () => new FileCredentialManager(encryption, ""));
- }
+ [Fact]
+ public void Constructor_NullDirectory_Throws()
+ {
+ // ArgumentException.ThrowIfNullOrWhiteSpace throws
+ // ArgumentNullException on null input (a subclass of ArgumentException).
+ var encryption = new LocalFileCredentialEncryption(Path.GetTempPath());
+ Assert.ThrowsAny(
+ () => new FileCredentialManager(encryption, null!));
+ }
- [Fact]
- public async Task AddCredentialAsync_ReturnsGuidAccountId()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ [Fact]
+ public void Constructor_EmptyDirectory_Throws()
+ {
+ var encryption = new LocalFileCredentialEncryption(Path.GetTempPath());
+ Assert.Throws(
+ () => new FileCredentialManager(encryption, ""));
+ }
- var accountId = await manager.AddCredentialAsync(
- providerName: "Adobe",
- accountName: "prod",
- environment: "Production",
- credentialData: "{\"apiKey\":\"x\"}");
+ [Fact]
+ public async Task AddCredentialAsync_ReturnsGuidAccountId()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- Assert.True(Guid.TryParse(accountId, out _));
- }
+ var accountId = await manager.AddCredentialAsync(
+ providerName: "Adobe",
+ accountName: "prod",
+ environment: "Production",
+ credentialData: "{\"apiKey\":\"x\"}");
- [Fact]
- public async Task AddCredentialAsync_CreatesFileAtExpectedPath()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ Assert.True(Guid.TryParse(accountId, out _));
+ }
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ [Fact]
+ public async Task AddCredentialAsync_CreatesFileAtExpectedPath()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- var expected = Path.Join(temp.Path, $"adobe_{accountId}.json");
- Assert.True(File.Exists(expected), $"expected credential file at {expected}");
- }
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- [Fact]
- public async Task AddCredentialAsync_LowercasesProviderPrefixInFilename()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ var expected = Path.Join(temp.Path, $"adobe_{accountId}.json");
+ Assert.True(File.Exists(expected), $"expected credential file at {expected}");
+ }
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ [Fact]
+ public async Task AddCredentialAsync_LowercasesProviderPrefixInFilename()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- var upperPath = Path.Join(temp.Path, $"Adobe_{accountId}.json");
- var lowerPath = Path.Join(temp.Path, $"adobe_{accountId}.json");
- Assert.True(File.Exists(lowerPath));
- // On case-insensitive filesystems this will also pass — we don't assert !File.Exists(upperPath).
- _ = upperPath;
- }
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- [Fact]
- public async Task ListCredentialsAsync_ReturnsAddedCredential()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
-
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
-
- var credential = Assert.Single(list);
- Assert.Equal(accountId, credential.AccountId);
- Assert.Equal("prod", credential.AccountName);
- Assert.Equal("Adobe", credential.ProviderName);
- Assert.Equal("Production", credential.Environment);
- Assert.False(credential.IsSelected);
- }
+ var upperPath = Path.Join(temp.Path, $"Adobe_{accountId}.json");
+ var lowerPath = Path.Join(temp.Path, $"adobe_{accountId}.json");
+ Assert.True(File.Exists(lowerPath));
+ // On case-insensitive filesystems this will also pass — we don't assert !File.Exists(upperPath).
+ _ = upperPath;
+ }
- [Fact]
- public async Task ListCredentialsAsync_FiltersByProvider()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
- _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
-
- var adobe = (await manager.ListCredentialsAsync("Adobe")).ToList();
- var airtable = (await manager.ListCredentialsAsync("Airtable")).ToList();
-
- var adobeCredential = Assert.Single(adobe);
- var airtableCredential = Assert.Single(airtable);
- Assert.Equal("Adobe", adobeCredential.ProviderName);
- Assert.Equal("Airtable", airtableCredential.ProviderName);
- }
+ [Fact]
+ public async Task ListCredentialsAsync_ReturnsAddedCredential()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+
+ var credential = Assert.Single(list);
+ Assert.Equal(accountId, credential.AccountId);
+ Assert.Equal("prod", credential.AccountName);
+ Assert.Equal("Adobe", credential.ProviderName);
+ Assert.Equal("Production", credential.Environment);
+ Assert.False(credential.IsSelected);
+ }
- [Fact]
- public async Task ListCredentialsAsync_IsCaseInsensitiveOnProviderName()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
+ [Fact]
+ public async Task ListCredentialsAsync_FiltersByProvider()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
+ _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
+
+ var adobe = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ var airtable = (await manager.ListCredentialsAsync("Airtable")).ToList();
+
+ var adobeCredential = Assert.Single(adobe);
+ var airtableCredential = Assert.Single(airtable);
+ Assert.Equal("Adobe", adobeCredential.ProviderName);
+ Assert.Equal("Airtable", airtableCredential.ProviderName);
+ }
- var list = (await manager.ListCredentialsAsync("ADOBE")).ToList();
+ [Fact]
+ public async Task ListCredentialsAsync_IsCaseInsensitiveOnProviderName()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
- Assert.Single(list);
- }
+ var list = (await manager.ListCredentialsAsync("ADOBE")).ToList();
- [Fact]
- public async Task ListCredentialsAsync_ReturnsEmpty_WhenNoMatching()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ Assert.Single(list);
+ }
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ [Fact]
+ public async Task ListCredentialsAsync_ReturnsEmpty_WhenNoMatching()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- Assert.Empty(list);
- }
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
- [Fact]
- public async Task SelectCredentialAsync_ReturnsTrue_WhenExists()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ Assert.Empty(list);
+ }
- var selected = await manager.SelectCredentialAsync(accountId);
+ [Fact]
+ public async Task SelectCredentialAsync_ReturnsTrue_WhenExists()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- Assert.True(selected);
- }
+ var selected = await manager.SelectCredentialAsync(accountId);
- [Fact]
- public async Task SelectCredentialAsync_ReturnsFalse_WhenNotFound()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ Assert.True(selected);
+ }
- var selected = await manager.SelectCredentialAsync(Guid.NewGuid().ToString());
+ [Fact]
+ public async Task SelectCredentialAsync_ReturnsFalse_WhenNotFound()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- Assert.False(selected);
- }
+ var selected = await manager.SelectCredentialAsync(Guid.NewGuid().ToString());
- [Fact]
- public async Task SelectCredentialAsync_ShowsSelectedInList()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- _ = await manager.SelectCredentialAsync(accountId);
+ Assert.False(selected);
+ }
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ [Fact]
+ public async Task SelectCredentialAsync_ShowsSelectedInList()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ _ = await manager.SelectCredentialAsync(accountId);
- Assert.True(list[0].IsSelected);
- }
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
- [Fact]
- public async Task GetSelectedCredentialAsync_ReturnsDecryptedPayload()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var payload = "{\"apiKey\":\"super-secret\"}";
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
- _ = await manager.SelectCredentialAsync(accountId);
+ Assert.True(list[0].IsSelected);
+ }
- var selected = await manager.GetSelectedCredentialAsync("Adobe");
+ [Fact]
+ public async Task GetSelectedCredentialAsync_ReturnsDecryptedPayload()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var payload = "{\"apiKey\":\"super-secret\"}";
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
+ _ = await manager.SelectCredentialAsync(accountId);
- Assert.Equal(payload, selected);
- }
+ var selected = await manager.GetSelectedCredentialAsync("Adobe");
- [Fact]
- public async Task GetSelectedCredentialAsync_ReturnsNull_WhenNoneSelected()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ Assert.Equal(payload, selected);
+ }
- var selected = await manager.GetSelectedCredentialAsync("Adobe");
+ [Fact]
+ public async Task GetSelectedCredentialAsync_ReturnsNull_WhenNoneSelected()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- Assert.Null(selected);
- }
+ var selected = await manager.GetSelectedCredentialAsync("Adobe");
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsDecryptedPayload_ForExistingAccount()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var payload = "{\"apiKey\":\"super-secret\"}";
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
+ Assert.Null(selected);
+ }
- var decrypted = await manager.GetCredentialByIdAsync("Adobe", accountId);
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsDecryptedPayload_ForExistingAccount()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var payload = "{\"apiKey\":\"super-secret\"}";
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
- Assert.Equal(payload, decrypted);
- }
+ var decrypted = await manager.GetCredentialByIdAsync("Adobe", accountId);
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsNull_ForUnknownAccountId()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ Assert.Equal(payload, decrypted);
+ }
- var result = await manager.GetCredentialByIdAsync("Adobe", Guid.NewGuid().ToString());
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsNull_ForUnknownAccountId()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- Assert.Null(result);
- }
+ var result = await manager.GetCredentialByIdAsync("Adobe", Guid.NewGuid().ToString());
- [Fact]
- public async Task GetCredentialByIdAsync_DoesNotMutateSelection()
- {
- // Regression: the whole reason this method exists is that consumers
- // needed to read non-selected credentials without the old
- // "select + read + restore" dance. Asserting the selection stays
- // put is the core contract.
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var selectedId = await manager.AddCredentialAsync("Adobe", "selected", "Production", "{\"a\":1}");
- var otherId = await manager.AddCredentialAsync("Adobe", "other", "Production", "{\"b\":2}");
- _ = await manager.SelectCredentialAsync(selectedId);
-
- _ = await manager.GetCredentialByIdAsync("Adobe", otherId);
-
- var listings = (await manager.ListCredentialsAsync("Adobe")).ToList();
- Assert.True(listings.Single(c => c.AccountId == selectedId).IsSelected);
- Assert.False(listings.Single(c => c.AccountId == otherId).IsSelected);
- }
+ Assert.Null(result);
+ }
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsNull_WhenProviderMismatches()
- {
- // Cross-provider isolation: an accountId belonging to provider X
- // must not surface when queried against provider Y.
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var adobeId = await manager.AddCredentialAsync("Adobe", "adobe-acct", "Production", "{}");
+ [Fact]
+ public async Task GetCredentialByIdAsync_DoesNotMutateSelection()
+ {
+ // Regression: the whole reason this method exists is that consumers
+ // needed to read non-selected credentials without the old
+ // "select + read + restore" dance. Asserting the selection stays
+ // put is the core contract.
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var selectedId = await manager.AddCredentialAsync("Adobe", "selected", "Production", "{\"a\":1}");
+ var otherId = await manager.AddCredentialAsync("Adobe", "other", "Production", "{\"b\":2}");
+ _ = await manager.SelectCredentialAsync(selectedId);
+
+ _ = await manager.GetCredentialByIdAsync("Adobe", otherId);
+
+ var listings = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ Assert.True(listings.Single(c => c.AccountId == selectedId).IsSelected);
+ Assert.False(listings.Single(c => c.AccountId == otherId).IsSelected);
+ }
- var result = await manager.GetCredentialByIdAsync("Airtable", adobeId);
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsNull_WhenProviderMismatches()
+ {
+ // Cross-provider isolation: an accountId belonging to provider X
+ // must not surface when queried against provider Y.
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var adobeId = await manager.AddCredentialAsync("Adobe", "adobe-acct", "Production", "{}");
- Assert.Null(result);
- }
+ var result = await manager.GetCredentialByIdAsync("Airtable", adobeId);
- [Fact]
- public async Task GetCredentialByIdAsync_WithInvalidProviderName_Throws()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ Assert.Null(result);
+ }
- await Assert.ThrowsAnyAsync(
- () => manager.GetCredentialByIdAsync(" ", Guid.NewGuid().ToString()));
- }
+ [Fact]
+ public async Task GetCredentialByIdAsync_WithInvalidProviderName_Throws()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- [Fact]
- public async Task GetCredentialByIdAsync_WithInvalidAccountId_Throws()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ await Assert.ThrowsAnyAsync(
+ () => manager.GetCredentialByIdAsync(" ", Guid.NewGuid().ToString()));
+ }
- await Assert.ThrowsAnyAsync(
- () => manager.GetCredentialByIdAsync("Adobe", " "));
- }
+ [Fact]
+ public async Task GetCredentialByIdAsync_WithInvalidAccountId_Throws()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- [Fact]
- public async Task DeleteCredentialAsync_RemovesFile()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- var filePath = Path.Join(temp.Path, $"adobe_{accountId}.json");
- Assert.True(File.Exists(filePath));
+ await Assert.ThrowsAnyAsync(
+ () => manager.GetCredentialByIdAsync("Adobe", " "));
+ }
- var deleted = await manager.DeleteCredentialAsync(accountId);
+ [Fact]
+ public async Task DeleteCredentialAsync_RemovesFile()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ var filePath = Path.Join(temp.Path, $"adobe_{accountId}.json");
+ Assert.True(File.Exists(filePath));
- Assert.True(deleted);
- Assert.False(File.Exists(filePath));
- }
+ var deleted = await manager.DeleteCredentialAsync(accountId);
- [Fact]
- public async Task DeleteCredentialAsync_ClearsSelection_IfItWasSelected()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- _ = await manager.SelectCredentialAsync(accountId);
+ Assert.True(deleted);
+ Assert.False(File.Exists(filePath));
+ }
- _ = await manager.DeleteCredentialAsync(accountId);
+ [Fact]
+ public async Task DeleteCredentialAsync_ClearsSelection_IfItWasSelected()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ _ = await manager.SelectCredentialAsync(accountId);
- Assert.Null(await manager.GetSelectedCredentialAsync("Adobe"));
- }
+ _ = await manager.DeleteCredentialAsync(accountId);
- [Fact]
- public async Task DeleteCredentialAsync_ReturnsFalse_WhenNotFound()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ Assert.Null(await manager.GetSelectedCredentialAsync("Adobe"));
+ }
- var deleted = await manager.DeleteCredentialAsync(Guid.NewGuid().ToString());
+ [Fact]
+ public async Task DeleteCredentialAsync_ReturnsFalse_WhenNotFound()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- Assert.False(deleted);
- }
+ var deleted = await manager.DeleteCredentialAsync(Guid.NewGuid().ToString());
- [Fact]
- public async Task GetProviderNamesAsync_ReturnsDistinctProviders()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
- _ = await manager.AddCredentialAsync("Adobe", "a2", "Sandbox", "{}");
- _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
+ Assert.False(deleted);
+ }
- var names = (await manager.GetProviderNamesAsync()).ToList();
+ [Fact]
+ public async Task GetProviderNamesAsync_ReturnsDistinctProviders()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
+ _ = await manager.AddCredentialAsync("Adobe", "a2", "Sandbox", "{}");
+ _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
- Assert.Equal(2, names.Count);
- Assert.Contains("Adobe", names);
- Assert.Contains("Airtable", names);
- }
+ var names = (await manager.GetProviderNamesAsync()).ToList();
- [Theory]
- [InlineData("")]
- [InlineData(" ")]
- [InlineData("../etc/passwd")]
- [InlineData("..\\windows\\system32")]
- [InlineData("pro*vider")]
- [InlineData("pro?vider")]
- [InlineData("pro/vider")]
- [InlineData("pro\\vider")]
- [InlineData("pro vider")]
- [InlineData("pro:vider")]
- [InlineData("proπvider")]
- public async Task AddCredentialAsync_InvalidProviderName_Throws(string providerName)
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ Assert.Equal(2, names.Count);
+ Assert.Contains("Adobe", names);
+ Assert.Contains("Airtable", names);
+ }
- await Assert.ThrowsAsync(
- () => manager.AddCredentialAsync(providerName, "name", "Production", "{}"));
- }
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("../etc/passwd")]
+ [InlineData("..\\windows\\system32")]
+ [InlineData("pro*vider")]
+ [InlineData("pro?vider")]
+ [InlineData("pro/vider")]
+ [InlineData("pro\\vider")]
+ [InlineData("pro vider")]
+ [InlineData("pro:vider")]
+ [InlineData("proπvider")]
+ public async Task AddCredentialAsync_InvalidProviderName_Throws(string providerName)
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- [Theory]
- [InlineData("Adobe")]
- [InlineData("my-provider")]
- [InlineData("my.provider")]
- [InlineData("my_provider")]
- [InlineData("Provider123")]
- [InlineData("ABC")]
- public async Task AddCredentialAsync_ValidProviderName_Succeeds(string providerName)
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ await Assert.ThrowsAsync(
+ () => manager.AddCredentialAsync(providerName, "name", "Production", "{}"));
+ }
- var accountId = await manager.AddCredentialAsync(providerName, "name", "Production", "{}");
+ [Theory]
+ [InlineData("Adobe")]
+ [InlineData("my-provider")]
+ [InlineData("my.provider")]
+ [InlineData("my_provider")]
+ [InlineData("Provider123")]
+ [InlineData("ABC")]
+ public async Task AddCredentialAsync_ValidProviderName_Succeeds(string providerName)
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- Assert.True(Guid.TryParse(accountId, out _));
- }
+ var accountId = await manager.AddCredentialAsync(providerName, "name", "Production", "{}");
- [Fact]
- public async Task AddCredentialAsync_SetsCredentialFileMode0600_OnUnix()
- {
- if (OperatingSystem.IsWindows())
- {
- return; // Unix-only assertion.
+ Assert.True(Guid.TryParse(accountId, out _));
}
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ [Fact]
+ public async Task AddCredentialAsync_SetsCredentialFileMode0600_OnUnix()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return; // Unix-only assertion.
+ }
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- var filePath = Path.Join(temp.Path, $"adobe_{accountId}.json");
- var mode = File.GetUnixFileMode(filePath);
- Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode);
- }
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- [Fact]
- public async Task ListCredentialsAsync_IncludesDisplayFields_FromSummaryProvider()
- {
- using var temp = new TempDir();
- var summary = new FakeAdobeSummaryProvider();
- var manager = CreateManager(temp.Path, [summary]);
+ var filePath = Path.Join(temp.Path, $"adobe_{accountId}.json");
+ var mode = File.GetUnixFileMode(filePath);
+ Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode);
+ }
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"abcd1234\"}");
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ [Fact]
+ public async Task ListCredentialsAsync_IncludesDisplayFields_FromSummaryProvider()
+ {
+ using var temp = new TempDir();
+ var summary = new FakeAdobeSummaryProvider();
+ var manager = CreateManager(temp.Path, [summary]);
- var credential = Assert.Single(list);
- var field = Assert.Single(credential.DisplayFields);
- Assert.Equal("Fingerprint", field.Key);
- Assert.Equal("abcd1234", field.Value);
- }
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"abcd1234\"}");
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
- [Fact]
- public async Task ListCredentialsAsync_LeavesDisplayFieldsEmpty_WhenNoProviderRegistered()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ var credential = Assert.Single(list);
+ var field = Assert.Single(credential.DisplayFields);
+ Assert.Equal("Fingerprint", field.Key);
+ Assert.Equal("abcd1234", field.Value);
+ }
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ [Fact]
+ public async Task ListCredentialsAsync_LeavesDisplayFieldsEmpty_WhenNoProviderRegistered()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- Assert.Empty(list[0].DisplayFields);
- }
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
- [Fact]
- public async Task Credential_Persists_AcrossManagerInstances()
- {
- using var temp = new TempDir();
+ Assert.Empty(list[0].DisplayFields);
+ }
- string accountId;
+ [Fact]
+ public async Task Credential_Persists_AcrossManagerInstances()
{
- var first = CreateManager(temp.Path);
- accountId = await first.AddCredentialAsync("Adobe", "prod", "Production", "{\"key\":\"v\"}");
- _ = await first.SelectCredentialAsync(accountId);
+ using var temp = new TempDir();
+
+ string accountId;
+ {
+ var first = CreateManager(temp.Path);
+ accountId = await first.AddCredentialAsync("Adobe", "prod", "Production", "{\"key\":\"v\"}");
+ _ = await first.SelectCredentialAsync(accountId);
+ }
+
+ var second = CreateManager(temp.Path);
+ var list = (await second.ListCredentialsAsync("Adobe")).ToList();
+
+ var credential = Assert.Single(list);
+ Assert.Equal(accountId, credential.AccountId);
+ Assert.True(credential.IsSelected);
+ Assert.Equal("{\"key\":\"v\"}", await second.GetSelectedCredentialAsync("Adobe"));
}
- var second = CreateManager(temp.Path);
- var list = (await second.ListCredentialsAsync("Adobe")).ToList();
-
- var credential = Assert.Single(list);
- Assert.Equal(accountId, credential.AccountId);
- Assert.True(credential.IsSelected);
- Assert.Equal("{\"key\":\"v\"}", await second.GetSelectedCredentialAsync("Adobe"));
- }
-
- [Theory]
- [InlineData("not-a-guid")]
- [InlineData("../etc/passwd")]
- [InlineData("*")]
- [InlineData("abc")]
- public async Task DeleteCredentialAsync_NonGuidId_ReturnsFalseWithoutThrowing(string accountId)
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
-
- // Malformed ids must not flow into Path.Join / Directory.GetFiles
- // glob — they should resolve to a clean "not found".
- var result = await manager.DeleteCredentialAsync(accountId);
-
- Assert.False(result);
- }
+ [Theory]
+ [InlineData("not-a-guid")]
+ [InlineData("../etc/passwd")]
+ [InlineData("*")]
+ [InlineData("abc")]
+ public async Task DeleteCredentialAsync_NonGuidId_ReturnsFalseWithoutThrowing(string accountId)
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- [Theory]
- [InlineData("not-a-guid")]
- [InlineData("../etc/passwd")]
- [InlineData("*")]
- public async Task SelectCredentialAsync_NonGuidId_ReturnsFalseWithoutThrowing(string accountId)
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ // Malformed ids must not flow into Path.Join / Directory.GetFiles
+ // glob — they should resolve to a clean "not found".
+ var result = await manager.DeleteCredentialAsync(accountId);
- var result = await manager.SelectCredentialAsync(accountId);
+ Assert.False(result);
+ }
- Assert.False(result);
- }
+ [Theory]
+ [InlineData("not-a-guid")]
+ [InlineData("../etc/passwd")]
+ [InlineData("*")]
+ public async Task SelectCredentialAsync_NonGuidId_ReturnsFalseWithoutThrowing(string accountId)
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- [Fact]
- public async Task GetCredentialByIdAsync_NonGuidId_Throws()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ var result = await manager.SelectCredentialAsync(accountId);
- await Assert.ThrowsAsync(
- () => manager.GetCredentialByIdAsync("Adobe", "not-a-guid"));
- }
+ Assert.False(result);
+ }
- [Fact]
- public async Task SelectCredentialAsync_ConcurrentDifferentProviders_BothPersist()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
-
- var adobeId = await manager.AddCredentialAsync("Adobe", "a", "Production", "{}");
- var airtableId = await manager.AddCredentialAsync("Airtable", "b", "Production", "{}");
-
- // Without the selections lock both calls would read the same
- // pre-write snapshot, mutate their own provider's entry, then both
- // save — last writer wins and one update is lost. With the lock
- // both updates must be observable afterwards.
- await Task.WhenAll(
- manager.SelectCredentialAsync(adobeId),
- manager.SelectCredentialAsync(airtableId));
-
- var adobe = (await manager.ListCredentialsAsync("Adobe")).Single();
- var airtable = (await manager.ListCredentialsAsync("Airtable")).Single();
- Assert.True(adobe.IsSelected);
- Assert.True(airtable.IsSelected);
- }
+ [Fact]
+ public async Task GetCredentialByIdAsync_NonGuidId_Throws()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
- [Fact]
- public async Task ExportCredentialsAsync_ReturnsDecryptedPayloadAndSelection()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var adobeId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
- _ = await manager.AddCredentialAsync("Airtable", "main", "Production", "{\"apiKey\":\"other\"}");
- Assert.True(await manager.SelectCredentialAsync(adobeId));
-
- var exports = await manager.ExportCredentialsAsync();
-
- Assert.Equal(2, exports.Count);
- var adobe = exports.Single(c => c.ProviderName == "Adobe");
- Assert.Equal("prod", adobe.AccountName);
- Assert.Equal("Production", adobe.Environment);
- Assert.Equal("{\"apiKey\":\"secret\"}", adobe.CredentialData); // decrypted
- Assert.True(adobe.IsSelected);
- Assert.False(exports.Single(c => c.ProviderName == "Airtable").IsSelected);
- }
+ await Assert.ThrowsAsync(
+ () => manager.GetCredentialByIdAsync("Adobe", "not-a-guid"));
+ }
- [Fact]
- public async Task RestoreCredentialAsync_PreservesAccountIdCreatedAtAndSelection()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
-
- var record = new CredentialExport
- {
- AccountId = Guid.NewGuid().ToString(),
- AccountName = "prod",
- ProviderName = "Adobe",
- Environment = "Production",
- CredentialData = "{\"apiKey\":\"restored\"}",
- CreatedAt = new DateTime(2018, 5, 6, 7, 8, 9, DateTimeKind.Utc),
- IsSelected = true,
- };
-
- await manager.RestoreCredentialAsync(record);
-
- var stored = Assert.Single(await manager.ExportCredentialsAsync());
- Assert.Equal(record.AccountId, stored.AccountId);
- Assert.Equal(record.CreatedAt, stored.CreatedAt);
- Assert.True(stored.IsSelected);
- Assert.Equal("{\"apiKey\":\"restored\"}", await manager.GetCredentialByIdAsync("Adobe", record.AccountId));
- }
+ [Fact]
+ public async Task SelectCredentialAsync_ConcurrentDifferentProviders_BothPersist()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+
+ var adobeId = await manager.AddCredentialAsync("Adobe", "a", "Production", "{}");
+ var airtableId = await manager.AddCredentialAsync("Airtable", "b", "Production", "{}");
+
+ // Without the selections lock both calls would read the same
+ // pre-write snapshot, mutate their own provider's entry, then both
+ // save — last writer wins and one update is lost. With the lock
+ // both updates must be observable afterwards.
+ await Task.WhenAll(
+ manager.SelectCredentialAsync(adobeId),
+ manager.SelectCredentialAsync(airtableId));
+
+ var adobe = (await manager.ListCredentialsAsync("Adobe")).Single();
+ var airtable = (await manager.ListCredentialsAsync("Airtable")).Single();
+ Assert.True(adobe.IsSelected);
+ Assert.True(airtable.IsSelected);
+ }
- [Fact]
- public async Task RestoreCredentialAsync_SameProviderAndId_ReplacesRatherThanDuplicates()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
- var id = Guid.NewGuid().ToString();
-
- CredentialExport Record(string payload) => new()
- {
- AccountId = id,
- AccountName = "prod",
- ProviderName = "Adobe",
- Environment = "Production",
- CredentialData = payload,
- CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc),
- IsSelected = false,
- };
-
- await manager.RestoreCredentialAsync(Record("{\"v\":1}"));
- await manager.RestoreCredentialAsync(Record("{\"v\":2}"));
-
- var stored = Assert.Single(await manager.ExportCredentialsAsync());
- Assert.Equal("{\"v\":2}", stored.CredentialData);
- }
+ [Fact]
+ public async Task ExportCredentialsAsync_ReturnsDecryptedPayloadAndSelection()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var adobeId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
+ _ = await manager.AddCredentialAsync("Airtable", "main", "Production", "{\"apiKey\":\"other\"}");
+ Assert.True(await manager.SelectCredentialAsync(adobeId));
+
+ var exports = await manager.ExportCredentialsAsync();
+
+ Assert.Equal(2, exports.Count);
+ var adobe = exports.Single(c => c.ProviderName == "Adobe");
+ Assert.Equal("prod", adobe.AccountName);
+ Assert.Equal("Production", adobe.Environment);
+ Assert.Equal("{\"apiKey\":\"secret\"}", adobe.CredentialData); // decrypted
+ Assert.True(adobe.IsSelected);
+ Assert.False(exports.Single(c => c.ProviderName == "Airtable").IsSelected);
+ }
- [Fact]
- public async Task RestoreCredentialAsync_InvalidAccountId_Throws()
- {
- using var temp = new TempDir();
- var manager = CreateManager(temp.Path);
+ [Fact]
+ public async Task RestoreCredentialAsync_PreservesAccountIdCreatedAtAndSelection()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+
+ var record = new CredentialExport
+ {
+ AccountId = Guid.NewGuid().ToString(),
+ AccountName = "prod",
+ ProviderName = "Adobe",
+ Environment = "Production",
+ CredentialData = "{\"apiKey\":\"restored\"}",
+ CreatedAt = new DateTime(2018, 5, 6, 7, 8, 9, DateTimeKind.Utc),
+ IsSelected = true,
+ };
+
+ await manager.RestoreCredentialAsync(record);
+
+ var stored = Assert.Single(await manager.ExportCredentialsAsync());
+ Assert.Equal(record.AccountId, stored.AccountId);
+ Assert.Equal(record.CreatedAt, stored.CreatedAt);
+ Assert.True(stored.IsSelected);
+ Assert.Equal("{\"apiKey\":\"restored\"}", await manager.GetCredentialByIdAsync("Adobe", record.AccountId));
+ }
- var bad = new CredentialExport
+ [Fact]
+ public async Task RestoreCredentialAsync_SameProviderAndId_ReplacesRatherThanDuplicates()
{
- AccountId = "../not-a-guid",
- AccountName = "prod",
- ProviderName = "Adobe",
- Environment = "Production",
- CredentialData = "{}",
- CreatedAt = DateTime.UtcNow,
- IsSelected = false,
- };
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+ var id = Guid.NewGuid().ToString();
+
+ CredentialExport Record(string payload) => new()
+ {
+ AccountId = id,
+ AccountName = "prod",
+ ProviderName = "Adobe",
+ Environment = "Production",
+ CredentialData = payload,
+ CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ IsSelected = false,
+ };
+
+ await manager.RestoreCredentialAsync(Record("{\"v\":1}"));
+ await manager.RestoreCredentialAsync(Record("{\"v\":2}"));
+
+ var stored = Assert.Single(await manager.ExportCredentialsAsync());
+ Assert.Equal("{\"v\":2}", stored.CredentialData);
+ }
- _ = await Assert.ThrowsAsync(() => manager.RestoreCredentialAsync(bad));
- }
+ [Fact]
+ public async Task RestoreCredentialAsync_InvalidAccountId_Throws()
+ {
+ using var temp = new TempDir();
+ var manager = CreateManager(temp.Path);
+
+ var bad = new CredentialExport
+ {
+ AccountId = "../not-a-guid",
+ AccountName = "prod",
+ ProviderName = "Adobe",
+ Environment = "Production",
+ CredentialData = "{}",
+ CreatedAt = DateTime.UtcNow,
+ IsSelected = false,
+ };
+
+ _ = await Assert.ThrowsAsync(() => manager.RestoreCredentialAsync(bad));
+ }
- ///
- /// Minimal summary provider used only to verify that
- /// routes
- /// decrypted data through the registered projection. Returns the raw
- /// payload under a single 'Fingerprint' column without any parsing.
- ///
- private sealed class FakeAdobeSummaryProvider : ICredentialSummaryProvider
- {
- public string ProviderName => "Adobe";
-
- public IReadOnlyList> GetDisplayFields(string decryptedCredentialJson)
- {
- // Pull the apiKey value back out of the minimal payload the test writes.
- // Keeping this parser-free so the test isn't coupled to System.Text.Json behaviour.
- const string token = "\"apiKey\":\"";
- var start = decryptedCredentialJson.IndexOf(token, StringComparison.Ordinal);
- if (start < 0) return [];
- start += token.Length;
- var end = decryptedCredentialJson.IndexOf('"', start);
- var value = decryptedCredentialJson[start..end];
- return [new("Fingerprint", value)];
+ ///
+ /// Minimal summary provider used only to verify that
+ /// routes
+ /// decrypted data through the registered projection. Returns the raw
+ /// payload under a single 'Fingerprint' column without any parsing.
+ ///
+ private sealed class FakeAdobeSummaryProvider : ICredentialSummaryProvider
+ {
+ public string ProviderName => "Adobe";
+
+ public IReadOnlyList> GetDisplayFields(string decryptedCredentialJson)
+ {
+ // Pull the apiKey value back out of the minimal payload the test writes.
+ // Keeping this parser-free so the test isn't coupled to System.Text.Json behaviour.
+ const string token = "\"apiKey\":\"";
+ var start = decryptedCredentialJson.IndexOf(token, StringComparison.Ordinal);
+ if (start < 0)
+ {
+ return [];
+ }
+
+ start += token.Length;
+ var end = decryptedCredentialJson.IndexOf('"', start);
+ var value = decryptedCredentialJson[start..end];
+ return [new("Fingerprint", value)];
+ }
}
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/KeychainCredentialManagerTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/KeychainCredentialManagerTests.cs
index 88b52aa..26af607 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/KeychainCredentialManagerTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/KeychainCredentialManagerTests.cs
@@ -1,393 +1,481 @@
using System.Runtime.Versioning;
using NextIteration.SpectreConsole.Auth.Commands;
-using NextIteration.SpectreConsole.Auth.Persistence;
using NextIteration.SpectreConsole.Auth.Persistence.Keychain;
using NextIteration.SpectreConsole.Auth.Tests.Infrastructure;
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Persistence;
-
-///
-/// Integration tests against the real macOS Keychain. Every test is gated on
-/// and early-returns (passing trivially)
-/// on Windows or Linux because the P/Invoke surface targets
-/// Security.framework exclusively.
-///
-///
-/// Each test uses a unique app-identifier per run (guid-suffixed) so
-/// concurrent test runs and stale items from a previous run don't collide.
-/// Best-effort cleanup runs on disposal but isn't load-bearing — the
-/// unique-identifier discipline is what keeps tests isolated.
-///
-[SupportedOSPlatform("macos")]
-public sealed class KeychainCredentialManagerTests : IDisposable
+namespace NextIteration.SpectreConsole.Auth.Tests.Persistence
{
- private readonly string _appIdentifier;
- private readonly bool _skip;
-
- public KeychainCredentialManagerTests()
+ ///
+ /// Integration tests against the real macOS Keychain. Every test is gated on
+ /// and early-returns (passing trivially)
+ /// on Windows or Linux because the P/Invoke surface targets
+ /// Security.framework exclusively.
+ ///
+ ///
+ /// Each test uses a unique app-identifier per run (guid-suffixed) so
+ /// concurrent test runs and stale items from a previous run don't collide.
+ /// Best-effort cleanup runs on disposal but isn't load-bearing — the
+ /// unique-identifier discipline is what keeps tests isolated.
+ ///
+ [SupportedOSPlatform("macos")]
+ public sealed class KeychainCredentialManagerTests : IDisposable
{
- _skip = !OperatingSystem.IsMacOS();
- _appIdentifier = $"test.nextiteration.sca.{Guid.NewGuid():N}";
- }
+ private readonly string _appIdentifier;
+ private readonly bool _skip;
- public void Dispose()
- {
- // Best-effort cleanup: delete everything this test added.
- if (_skip) return;
- TryCleanup();
- }
+ public KeychainCredentialManagerTests()
+ {
+ _skip = !OperatingSystem.IsMacOS();
+ _appIdentifier = $"test.nextiteration.sca.{Guid.NewGuid():N}";
+ }
- private void TryCleanup()
- {
-#pragma warning disable CA1416 // Validated by _skip check in Dispose().
- try
+ public void Dispose()
{
- var manager = new KeychainCredentialManager(_appIdentifier);
- foreach (var provider in manager.GetProviderNamesAsync().GetAwaiter().GetResult())
+ // Best-effort cleanup: delete everything this test added.
+ if (_skip)
{
- foreach (var summary in manager.ListCredentialsAsync(provider).GetAwaiter().GetResult())
- {
- _ = manager.DeleteCredentialAsync(summary.AccountId).GetAwaiter().GetResult();
- }
+ return;
}
+
+ TryCleanup();
}
- catch
+
+ private void TryCleanup()
{
- // Swallow — cleanup is a nicety, not a contract.
- }
+#pragma warning disable CA1416 // Validated by _skip check in Dispose().
+ try
+ {
+ var manager = new KeychainCredentialManager(_appIdentifier);
+ foreach (var provider in manager.GetProviderNamesAsync().GetAwaiter().GetResult())
+ {
+ foreach (var summary in manager.ListCredentialsAsync(provider).GetAwaiter().GetResult())
+ {
+ _ = manager.DeleteCredentialAsync(summary.AccountId).GetAwaiter().GetResult();
+ }
+ }
+ }
+ catch
+ {
+ // Swallow — cleanup is a nicety, not a contract.
+ }
#pragma warning restore CA1416
- }
+ }
- private KeychainCredentialManager NewManager(IEnumerable? summary = null)
- {
+ private KeychainCredentialManager NewManager(IEnumerable? summary = null) =>
#pragma warning disable CA1416 // Validated by _skip check in each test.
- return new KeychainCredentialManager(_appIdentifier, summary);
+ new(_appIdentifier, summary);
#pragma warning restore CA1416
- }
- [Fact]
- public async Task ExportCredentialsAsync_ReturnsDecryptedPayloadAndSelection()
- {
- if (_skip) return;
- var manager = NewManager();
- var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
- Assert.True(await manager.SelectCredentialAsync(id));
-
- var adobe = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
- Assert.Equal(id, adobe.AccountId);
- Assert.Equal("prod", adobe.AccountName);
- Assert.Equal("Adobe", adobe.ProviderName);
- Assert.Equal("Production", adobe.Environment);
- Assert.Equal("{\"apiKey\":\"secret\"}", adobe.CredentialData);
- Assert.True(adobe.IsSelected);
- }
- [Fact]
- public async Task RestoreCredentialAsync_PreservesAccountIdAndSelection()
- {
- // The macOS Keychain assigns its own creation date, so CreatedAt is
- // intentionally not asserted here (see RestoreCredentialAsync remarks).
- if (_skip) return;
- var manager = NewManager();
- var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
- _ = await manager.SelectCredentialAsync(id);
- var exported = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
-
- // Simulate an import: drop it, then restore from the export record.
- Assert.True(await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(id)));
- await manager.RestoreCredentialAsync(exported);
-
- var restored = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
- Assert.Equal(id, restored.AccountId);
- Assert.True(restored.IsSelected);
- Assert.Equal("{\"apiKey\":\"secret\"}", await manager.GetSelectedCredentialAsync("Adobe"));
- }
+ [Fact]
+ public async Task ExportCredentialsAsync_ReturnsDecryptedPayloadAndSelection()
+ {
+ if (_skip)
+ {
+ return;
+ }
- [Fact]
- public async Task AddCredentialAsync_ReturnsGuidAccountId()
- {
- if (_skip) return;
- var manager = NewManager();
+ var manager = NewManager();
+ var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
+ Assert.True(await RetryHelper.UntilTrueAsync(() => manager.SelectCredentialAsync(id)));
+
+ var adobe = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
+ Assert.Equal(id, adobe.AccountId);
+ Assert.Equal("prod", adobe.AccountName);
+ Assert.Equal("Adobe", adobe.ProviderName);
+ Assert.Equal("Production", adobe.Environment);
+ Assert.Equal("{\"apiKey\":\"secret\"}", adobe.CredentialData);
+ Assert.True(adobe.IsSelected);
+ }
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ [Fact]
+ public async Task RestoreCredentialAsync_PreservesAccountIdAndSelection()
+ {
+ // The macOS Keychain assigns its own creation date, so CreatedAt is
+ // intentionally not asserted here (see RestoreCredentialAsync remarks).
+ if (_skip)
+ {
+ return;
+ }
- Assert.True(Guid.TryParse(accountId, out _));
- }
+ var manager = NewManager();
+ var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
+ _ = await manager.SelectCredentialAsync(id);
+ var exported = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
- [Fact]
- public async Task ListCredentialsAsync_ReturnsAddedCredential()
- {
- if (_skip) return;
- var manager = NewManager();
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
-
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
-
- var credential = Assert.Single(list);
- Assert.Equal(accountId, credential.AccountId);
- Assert.Equal("prod", credential.AccountName);
- Assert.Equal("Adobe", credential.ProviderName);
- Assert.Equal("Production", credential.Environment);
- Assert.False(credential.IsSelected);
- }
+ // Simulate an import: drop it, then restore from the export record.
+ Assert.True(await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(id)));
+ await manager.RestoreCredentialAsync(exported);
- [Fact]
- public async Task ListCredentialsAsync_FiltersByProvider()
- {
- if (_skip) return;
- var manager = NewManager();
- _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
- _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
-
- var adobe = (await manager.ListCredentialsAsync("Adobe")).ToList();
- var airtable = (await manager.ListCredentialsAsync("Airtable")).ToList();
-
- var adobeCredential = Assert.Single(adobe);
- var airtableCredential = Assert.Single(airtable);
- Assert.Equal("Adobe", adobeCredential.ProviderName);
- Assert.Equal("Airtable", airtableCredential.ProviderName);
- }
+ var restored = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
+ Assert.Equal(id, restored.AccountId);
+ Assert.True(restored.IsSelected);
+ Assert.Equal("{\"apiKey\":\"secret\"}", await manager.GetSelectedCredentialAsync("Adobe"));
+ }
- [Fact]
- public async Task SelectAndGetSelected_RoundTripsDecryptedPayload()
- {
- if (_skip) return;
- var manager = NewManager();
- var payload = "{\"apiKey\":\"super-secret\"}";
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
+ [Fact]
+ public async Task AddCredentialAsync_ReturnsGuidAccountId()
+ {
+ if (_skip)
+ {
+ return;
+ }
- Assert.True(await manager.SelectCredentialAsync(accountId));
- var selected = await manager.GetSelectedCredentialAsync("Adobe");
+ var manager = NewManager();
- Assert.Equal(payload, selected);
- }
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- [Fact]
- public async Task SelectCredentialAsync_ReturnsFalse_WhenNotFound()
- {
- if (_skip) return;
- var manager = NewManager();
+ Assert.True(Guid.TryParse(accountId, out _));
+ }
- var selected = await manager.SelectCredentialAsync(Guid.NewGuid().ToString());
+ [Fact]
+ public async Task ListCredentialsAsync_ReturnsAddedCredential()
+ {
+ if (_skip)
+ {
+ return;
+ }
- Assert.False(selected);
- }
+ var manager = NewManager();
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- [Fact]
- public async Task GetSelectedCredentialAsync_ReturnsNull_WhenNoneSelected()
- {
- if (_skip) return;
- var manager = NewManager();
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
- var selected = await manager.GetSelectedCredentialAsync("Adobe");
+ var credential = Assert.Single(list);
+ Assert.Equal(accountId, credential.AccountId);
+ Assert.Equal("prod", credential.AccountName);
+ Assert.Equal("Adobe", credential.ProviderName);
+ Assert.Equal("Production", credential.Environment);
+ Assert.False(credential.IsSelected);
+ }
- Assert.Null(selected);
- }
+ [Fact]
+ public async Task ListCredentialsAsync_FiltersByProvider()
+ {
+ if (_skip)
+ {
+ return;
+ }
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsDecryptedPayload_ForExistingAccount()
- {
- if (_skip) return;
- var manager = NewManager();
- var payload = "{\"apiKey\":\"super-secret\"}";
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
+ var manager = NewManager();
+ _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
+ _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
- var decrypted = await manager.GetCredentialByIdAsync("Adobe", accountId);
+ var adobe = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ var airtable = (await manager.ListCredentialsAsync("Airtable")).ToList();
- Assert.Equal(payload, decrypted);
- }
+ var adobeCredential = Assert.Single(adobe);
+ var airtableCredential = Assert.Single(airtable);
+ Assert.Equal("Adobe", adobeCredential.ProviderName);
+ Assert.Equal("Airtable", airtableCredential.ProviderName);
+ }
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsNull_ForUnknownAccountId()
- {
- if (_skip) return;
- var manager = NewManager();
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ [Fact]
+ public async Task SelectAndGetSelected_RoundTripsDecryptedPayload()
+ {
+ if (_skip)
+ {
+ return;
+ }
- var result = await manager.GetCredentialByIdAsync("Adobe", Guid.NewGuid().ToString());
+ var manager = NewManager();
+ var payload = "{\"apiKey\":\"super-secret\"}";
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
- Assert.Null(result);
- }
+ Assert.True(await RetryHelper.UntilTrueAsync(() => manager.SelectCredentialAsync(accountId)));
+ var selected = await manager.GetSelectedCredentialAsync("Adobe");
- [Fact]
- public async Task GetCredentialByIdAsync_DoesNotMutateSelection()
- {
- if (_skip) return;
- var manager = NewManager();
- var selectedId = await manager.AddCredentialAsync("Adobe", "selected", "Production", "{\"a\":1}");
- var otherId = await manager.AddCredentialAsync("Adobe", "other", "Production", "{\"b\":2}");
- _ = await manager.SelectCredentialAsync(selectedId);
+ Assert.Equal(payload, selected);
+ }
- _ = await manager.GetCredentialByIdAsync("Adobe", otherId);
+ [Fact]
+ public async Task SelectCredentialAsync_ReturnsFalse_WhenNotFound()
+ {
+ if (_skip)
+ {
+ return;
+ }
- var listings = (await manager.ListCredentialsAsync("Adobe")).ToList();
- Assert.True(listings.Single(c => c.AccountId == selectedId).IsSelected);
- Assert.False(listings.Single(c => c.AccountId == otherId).IsSelected);
- }
+ var manager = NewManager();
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsNull_WhenProviderMismatches()
- {
- if (_skip) return;
- var manager = NewManager();
- var adobeId = await manager.AddCredentialAsync("Adobe", "adobe-acct", "Production", "{}");
+ var selected = await manager.SelectCredentialAsync(Guid.NewGuid().ToString());
- var result = await manager.GetCredentialByIdAsync("Airtable", adobeId);
+ Assert.False(selected);
+ }
- Assert.Null(result);
- }
+ [Fact]
+ public async Task GetSelectedCredentialAsync_ReturnsNull_WhenNoneSelected()
+ {
+ if (_skip)
+ {
+ return;
+ }
- [Fact]
- public async Task DeleteCredentialAsync_RemovesCredential()
- {
- if (_skip) return;
- var manager = NewManager();
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ var manager = NewManager();
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- var deleted = await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(accountId));
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ var selected = await manager.GetSelectedCredentialAsync("Adobe");
- Assert.True(deleted);
- Assert.Empty(list);
- }
+ Assert.Null(selected);
+ }
- [Fact]
- public async Task DeleteCredentialAsync_ClearsSelection_IfItWasSelected()
- {
- if (_skip) return;
- var manager = NewManager();
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- _ = await manager.SelectCredentialAsync(accountId);
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsDecryptedPayload_ForExistingAccount()
+ {
+ if (_skip)
+ {
+ return;
+ }
- _ = await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(accountId));
+ var manager = NewManager();
+ var payload = "{\"apiKey\":\"super-secret\"}";
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
- Assert.Null(await manager.GetSelectedCredentialAsync("Adobe"));
- }
+ var decrypted = await manager.GetCredentialByIdAsync("Adobe", accountId);
- [Fact]
- public async Task DeleteCredentialAsync_ReturnsFalse_WhenNotFound()
- {
- if (_skip) return;
- var manager = NewManager();
+ Assert.Equal(payload, decrypted);
+ }
- var deleted = await manager.DeleteCredentialAsync(Guid.NewGuid().ToString());
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsNull_ForUnknownAccountId()
+ {
+ if (_skip)
+ {
+ return;
+ }
- Assert.False(deleted);
- }
+ var manager = NewManager();
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- [Fact]
- public async Task GetProviderNamesAsync_ReturnsDistinctProviders()
- {
- if (_skip) return;
- var manager = NewManager();
- _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
- _ = await manager.AddCredentialAsync("Adobe", "a2", "Sandbox", "{}");
- _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
+ var result = await manager.GetCredentialByIdAsync("Adobe", Guid.NewGuid().ToString());
- var names = (await manager.GetProviderNamesAsync()).ToList();
+ Assert.Null(result);
+ }
- Assert.Equal(2, names.Count);
- Assert.Contains("Adobe", names);
- Assert.Contains("Airtable", names);
- }
+ [Fact]
+ public async Task GetCredentialByIdAsync_DoesNotMutateSelection()
+ {
+ if (_skip)
+ {
+ return;
+ }
- [Fact]
- public async Task Credentials_AreIsolated_ByAppIdentifier()
- {
- if (_skip) return;
+ var manager = NewManager();
+ var selectedId = await manager.AddCredentialAsync("Adobe", "selected", "Production", "{\"a\":1}");
+ var otherId = await manager.AddCredentialAsync("Adobe", "other", "Production", "{\"b\":2}");
+ _ = await manager.SelectCredentialAsync(selectedId);
- // Create a neighbour app that shouldn't see our items.
- var neighbourIdentifier = $"test.nextiteration.sca.neighbour.{Guid.NewGuid():N}";
-#pragma warning disable CA1416
- var neighbour = new KeychainCredentialManager(neighbourIdentifier);
-#pragma warning restore CA1416
- try
+ _ = await manager.GetCredentialByIdAsync("Adobe", otherId);
+
+ var listings = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ Assert.True(listings.Single(c => c.AccountId == selectedId).IsSelected);
+ Assert.False(listings.Single(c => c.AccountId == otherId).IsSelected);
+ }
+
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsNull_WhenProviderMismatches()
+ {
+ if (_skip)
+ {
+ return;
+ }
+
+ var manager = NewManager();
+ var adobeId = await manager.AddCredentialAsync("Adobe", "adobe-acct", "Production", "{}");
+
+ var result = await manager.GetCredentialByIdAsync("Airtable", adobeId);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task DeleteCredentialAsync_RemovesCredential()
+ {
+ if (_skip)
+ {
+ return;
+ }
+
+ var manager = NewManager();
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+
+ var deleted = await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(accountId));
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+
+ Assert.True(deleted);
+ Assert.Empty(list);
+ }
+
+ [Fact]
+ public async Task DeleteCredentialAsync_ClearsSelection_IfItWasSelected()
{
- var us = NewManager();
- _ = await us.AddCredentialAsync("Adobe", "ours", "Production", "{}");
+ if (_skip)
+ {
+ return;
+ }
+
+ var manager = NewManager();
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ _ = await manager.SelectCredentialAsync(accountId);
- var neighbourList = (await neighbour.ListCredentialsAsync("Adobe")).ToList();
- Assert.Empty(neighbourList);
+ _ = await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(accountId));
+
+ Assert.Null(await manager.GetSelectedCredentialAsync("Adobe"));
}
- finally
+
+ [Fact]
+ public async Task DeleteCredentialAsync_ReturnsFalse_WhenNotFound()
{
- // Clean up neighbour.
- foreach (var p in await neighbour.GetProviderNamesAsync())
+ if (_skip)
{
- foreach (var s in await neighbour.ListCredentialsAsync(p))
+ return;
+ }
+
+ var manager = NewManager();
+
+ var deleted = await manager.DeleteCredentialAsync(Guid.NewGuid().ToString());
+
+ Assert.False(deleted);
+ }
+
+ [Fact]
+ public async Task GetProviderNamesAsync_ReturnsDistinctProviders()
+ {
+ if (_skip)
+ {
+ return;
+ }
+
+ var manager = NewManager();
+ _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
+ _ = await manager.AddCredentialAsync("Adobe", "a2", "Sandbox", "{}");
+ _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
+
+ var names = (await manager.GetProviderNamesAsync()).ToList();
+
+ Assert.Equal(2, names.Count);
+ Assert.Contains("Adobe", names);
+ Assert.Contains("Airtable", names);
+ }
+
+ [Fact]
+ public async Task Credentials_AreIsolated_ByAppIdentifier()
+ {
+ if (_skip)
+ {
+ return;
+ }
+
+ // Create a neighbour app that shouldn't see our items.
+ var neighbourIdentifier = $"test.nextiteration.sca.neighbour.{Guid.NewGuid():N}";
+#pragma warning disable CA1416
+ var neighbour = new KeychainCredentialManager(neighbourIdentifier);
+#pragma warning restore CA1416
+ try
+ {
+ var us = NewManager();
+ _ = await us.AddCredentialAsync("Adobe", "ours", "Production", "{}");
+
+ var neighbourList = (await neighbour.ListCredentialsAsync("Adobe")).ToList();
+ Assert.Empty(neighbourList);
+ }
+ finally
+ {
+ // Clean up neighbour.
+ foreach (var p in await neighbour.GetProviderNamesAsync())
{
- _ = await neighbour.DeleteCredentialAsync(s.AccountId);
+ foreach (var s in await neighbour.ListCredentialsAsync(p))
+ {
+ _ = await neighbour.DeleteCredentialAsync(s.AccountId);
+ }
}
}
}
- }
- [Theory]
- [InlineData("")]
- [InlineData(" ")]
- [InlineData("../etc/passwd")]
- [InlineData("pro*vider")]
- [InlineData("pro vider")]
- public async Task AddCredentialAsync_InvalidProviderName_Throws(string providerName)
- {
- if (_skip) return;
- var manager = NewManager();
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("../etc/passwd")]
+ [InlineData("pro*vider")]
+ [InlineData("pro vider")]
+ public async Task AddCredentialAsync_InvalidProviderName_Throws(string providerName)
+ {
+ if (_skip)
+ {
+ return;
+ }
- await Assert.ThrowsAnyAsync(
- () => manager.AddCredentialAsync(providerName, "name", "Production", "{}"));
- }
+ var manager = NewManager();
- [Fact]
- public void Constructor_NullAppIdentifier_Throws()
- {
- if (_skip) return;
+ await Assert.ThrowsAnyAsync(
+ () => manager.AddCredentialAsync(providerName, "name", "Production", "{}"));
+ }
+
+ [Fact]
+ public void Constructor_NullAppIdentifier_Throws()
+ {
+ if (_skip)
+ {
+ return;
+ }
#pragma warning disable CA1416
- Assert.ThrowsAny(() => new KeychainCredentialManager(null!));
+ Assert.ThrowsAny(() => new KeychainCredentialManager(null!));
#pragma warning restore CA1416
- }
+ }
- [Fact]
- public void Constructor_EmptyAppIdentifier_Throws()
- {
- if (_skip) return;
+ [Fact]
+ public void Constructor_EmptyAppIdentifier_Throws()
+ {
+ if (_skip)
+ {
+ return;
+ }
#pragma warning disable CA1416
- Assert.ThrowsAny(() => new KeychainCredentialManager(""));
+ Assert.ThrowsAny(() => new KeychainCredentialManager(""));
#pragma warning restore CA1416
- }
+ }
- [Fact]
- public async Task ListCredentialsAsync_IncludesDisplayFields_FromSummaryProvider()
- {
- if (_skip) return;
- var summaryProvider = new FakeAdobeSummaryProvider();
- var manager = NewManager([summaryProvider]);
+ [Fact]
+ public async Task ListCredentialsAsync_IncludesDisplayFields_FromSummaryProvider()
+ {
+ if (_skip)
+ {
+ return;
+ }
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"xyz\"}");
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ var summaryProvider = new FakeAdobeSummaryProvider();
+ var manager = NewManager([summaryProvider]);
- var credential = Assert.Single(list);
- var field = Assert.Single(credential.DisplayFields);
- Assert.Equal("Fingerprint", field.Key);
- Assert.Equal("xyz", field.Value);
- }
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"xyz\"}");
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
- private sealed class FakeAdobeSummaryProvider : ICredentialSummaryProvider
- {
- public string ProviderName => "Adobe";
+ var credential = Assert.Single(list);
+ var field = Assert.Single(credential.DisplayFields);
+ Assert.Equal("Fingerprint", field.Key);
+ Assert.Equal("xyz", field.Value);
+ }
- public IReadOnlyList> GetDisplayFields(string decryptedCredentialJson)
+ private sealed class FakeAdobeSummaryProvider : ICredentialSummaryProvider
{
- const string token = "\"apiKey\":\"";
- var start = decryptedCredentialJson.IndexOf(token, StringComparison.Ordinal);
- if (start < 0) return [];
- start += token.Length;
- var end = decryptedCredentialJson.IndexOf('"', start);
- var value = decryptedCredentialJson[start..end];
- return [new("Fingerprint", value)];
+ public string ProviderName => "Adobe";
+
+ public IReadOnlyList> GetDisplayFields(string decryptedCredentialJson)
+ {
+ const string token = "\"apiKey\":\"";
+ var start = decryptedCredentialJson.IndexOf(token, StringComparison.Ordinal);
+ if (start < 0)
+ {
+ return [];
+ }
+
+ start += token.Length;
+ var end = decryptedCredentialJson.IndexOf('"', start);
+ var value = decryptedCredentialJson[start..end];
+ return [new("Fingerprint", value)];
+ }
}
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/LibsecretCredentialManagerTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/LibsecretCredentialManagerTests.cs
index cbeb557..8e54ebb 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/LibsecretCredentialManagerTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/LibsecretCredentialManagerTests.cs
@@ -1,416 +1,505 @@
using System.Runtime.Versioning;
using NextIteration.SpectreConsole.Auth.Commands;
-using NextIteration.SpectreConsole.Auth.Persistence;
using NextIteration.SpectreConsole.Auth.Persistence.Libsecret;
using NextIteration.SpectreConsole.Auth.Tests.Infrastructure;
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Persistence;
-
-///
-/// Integration tests against the real Linux Secret Service (libsecret).
-/// Every test is gated on and on a
-/// best-effort "is the Secret Service daemon actually reachable" probe.
-/// Linux environments without a running keyring daemon (minimal containers,
-/// SSH-only servers, CI without the workflow setup) cause the probe to
-/// return false and tests pass vacuously.
-///
-///
-///
-/// Each test uses a unique app identifier per run (guid-suffixed) so
-/// stale items from a previous failed run don't collide.
-///
-///
-/// Tests target the "session" collection (in-memory, always present
-/// on a running daemon). The default "default"/login collection
-/// requires provisioning a login.keyring file on disk, which fresh
-/// CI runners don't have — targeting "session" side-steps that
-/// without any CI bootstrap gymnastics.
-///
-///
-[SupportedOSPlatform("linux")]
-public sealed class LibsecretCredentialManagerTests : IDisposable
+namespace NextIteration.SpectreConsole.Auth.Tests.Persistence
{
- private const string TestCollection = "session";
-
- private readonly string _appIdentifier;
- private readonly bool _skip;
-
- public LibsecretCredentialManagerTests()
- {
- _appIdentifier = $"test.nextiteration.sca.{Guid.NewGuid():N}";
- _skip = !OperatingSystem.IsLinux() || !IsSecretServiceAvailable();
- }
-
///
- /// Best-effort probe: try a store + clear against the session collection
- /// and treat any exception as "Secret Service isn't available." A bare
- /// search doesn't exercise the collection write path, so we do a real
- /// round-trip. Not running on Linux counts as unavailable too so the
- /// test class compiles clean on any platform.
+ /// Integration tests against the real Linux Secret Service (libsecret).
+ /// Every test is gated on and on a
+ /// best-effort "is the Secret Service daemon actually reachable" probe.
+ /// Linux environments without a running keyring daemon (minimal containers,
+ /// SSH-only servers, CI without the workflow setup) cause the probe to
+ /// return false and tests pass vacuously.
///
- private static bool IsSecretServiceAvailable()
+ ///
+ ///
+ /// Each test uses a unique app identifier per run (guid-suffixed) so
+ /// stale items from a previous failed run don't collide.
+ ///
+ ///
+ /// Tests target the "session" collection (in-memory, always present
+ /// on a running daemon). The default "default"/login collection
+ /// requires provisioning a login.keyring file on disk, which fresh
+ /// CI runners don't have — targeting "session" side-steps that
+ /// without any CI bootstrap gymnastics.
+ ///
+ ///
+ [SupportedOSPlatform("linux")]
+ public sealed class LibsecretCredentialManagerTests : IDisposable
{
- if (!OperatingSystem.IsLinux()) return false;
- try
+ private const string TestCollection = "session";
+
+ private readonly string _appIdentifier;
+ private readonly bool _skip;
+
+ public LibsecretCredentialManagerTests()
+ {
+ _appIdentifier = $"test.nextiteration.sca.{Guid.NewGuid():N}";
+ _skip = !OperatingSystem.IsLinux() || !IsSecretServiceAvailable();
+ }
+
+ ///
+ /// Best-effort probe: try a store + clear against the session collection
+ /// and treat any exception as "Secret Service isn't available." A bare
+ /// search doesn't exercise the collection write path, so we do a real
+ /// round-trip. Not running on Linux counts as unavailable too so the
+ /// test class compiles clean on any platform.
+ ///
+ private static bool IsSecretServiceAvailable()
{
+ if (!OperatingSystem.IsLinux())
+ {
+ return false;
+ }
+
+ try
+ {
#pragma warning disable CA1416
- var probe = new LibsecretCredentialManager(
- $"probe.{Guid.NewGuid():N}",
- collection: TestCollection);
- var id = probe.AddCredentialAsync("Probe", "probe", "Probe", "{}").GetAwaiter().GetResult();
- _ = probe.DeleteCredentialAsync(id).GetAwaiter().GetResult();
+ var probe = new LibsecretCredentialManager(
+ $"probe.{Guid.NewGuid():N}",
+ collection: TestCollection);
+ var id = probe.AddCredentialAsync("Probe", "probe", "Probe", "{}").GetAwaiter().GetResult();
+ _ = probe.DeleteCredentialAsync(id).GetAwaiter().GetResult();
#pragma warning restore CA1416
- return true;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
}
- catch
+
+ public void Dispose()
{
- return false;
- }
- }
+ if (_skip)
+ {
+ return;
+ }
- public void Dispose()
- {
- if (_skip) return;
- TryCleanup();
- }
+ TryCleanup();
+ }
- private void TryCleanup()
- {
-#pragma warning disable CA1416
- try
+ private void TryCleanup()
{
- var manager = new LibsecretCredentialManager(_appIdentifier, collection: TestCollection);
- foreach (var provider in manager.GetProviderNamesAsync().GetAwaiter().GetResult())
+#pragma warning disable CA1416
+ try
{
- foreach (var summary in manager.ListCredentialsAsync(provider).GetAwaiter().GetResult())
+ var manager = new LibsecretCredentialManager(_appIdentifier, collection: TestCollection);
+ foreach (var provider in manager.GetProviderNamesAsync().GetAwaiter().GetResult())
{
- _ = manager.DeleteCredentialAsync(summary.AccountId).GetAwaiter().GetResult();
+ foreach (var summary in manager.ListCredentialsAsync(provider).GetAwaiter().GetResult())
+ {
+ _ = manager.DeleteCredentialAsync(summary.AccountId).GetAwaiter().GetResult();
+ }
}
}
- }
- catch
- {
- // Swallow — cleanup is a nicety, not a contract.
- }
+ catch
+ {
+ // Swallow — cleanup is a nicety, not a contract.
+ }
#pragma warning restore CA1416
- }
+ }
- private LibsecretCredentialManager NewManager(IEnumerable? summary = null)
- {
+ private LibsecretCredentialManager NewManager(IEnumerable? summary = null) =>
#pragma warning disable CA1416
- return new LibsecretCredentialManager(_appIdentifier, summary, TestCollection);
+ new(_appIdentifier, summary, TestCollection);
#pragma warning restore CA1416
- }
- [Fact]
- public async Task ExportCredentialsAsync_ReturnsDecryptedPayloadAndSelection()
- {
- if (_skip) return;
- var manager = NewManager();
- var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
- Assert.True(await manager.SelectCredentialAsync(id));
-
- var adobe = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
- Assert.Equal(id, adobe.AccountId);
- Assert.Equal("prod", adobe.AccountName);
- Assert.Equal("Adobe", adobe.ProviderName);
- Assert.Equal("Production", adobe.Environment);
- Assert.Equal("{\"apiKey\":\"secret\"}", adobe.CredentialData);
- Assert.True(adobe.IsSelected);
- }
- [Fact]
- public async Task RestoreCredentialAsync_PreservesAccountIdCreatedAtAndSelection()
- {
- if (_skip) return;
- var manager = NewManager();
- var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
- _ = await manager.SelectCredentialAsync(id);
- var exported = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
-
- // Simulate an import: drop it, then restore from the export record.
- Assert.True(await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(id)));
- await manager.RestoreCredentialAsync(exported);
-
- var restored = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
- Assert.Equal(id, restored.AccountId);
- Assert.Equal(exported.CreatedAt, restored.CreatedAt); // libsecret preserves it via attribute
- Assert.True(restored.IsSelected);
- Assert.Equal("{\"apiKey\":\"secret\"}", await manager.GetSelectedCredentialAsync("Adobe"));
- }
+ [Fact]
+ public async Task ExportCredentialsAsync_ReturnsDecryptedPayloadAndSelection()
+ {
+ if (_skip)
+ {
+ return;
+ }
- [Fact]
- public async Task AddCredentialAsync_ReturnsGuidAccountId()
- {
- if (_skip) return;
- var manager = NewManager();
+ var manager = NewManager();
+ var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
+ Assert.True(await RetryHelper.UntilTrueAsync(() => manager.SelectCredentialAsync(id)));
+
+ var adobe = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
+ Assert.Equal(id, adobe.AccountId);
+ Assert.Equal("prod", adobe.AccountName);
+ Assert.Equal("Adobe", adobe.ProviderName);
+ Assert.Equal("Production", adobe.Environment);
+ Assert.Equal("{\"apiKey\":\"secret\"}", adobe.CredentialData);
+ Assert.True(adobe.IsSelected);
+ }
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ [Fact]
+ public async Task RestoreCredentialAsync_PreservesAccountIdCreatedAtAndSelection()
+ {
+ if (_skip)
+ {
+ return;
+ }
- Assert.True(Guid.TryParse(accountId, out _));
- }
+ var manager = NewManager();
+ var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}");
+ _ = await manager.SelectCredentialAsync(id);
+ var exported = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
- [Fact]
- public async Task ListCredentialsAsync_ReturnsAddedCredential()
- {
- if (_skip) return;
- var manager = NewManager();
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
-
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
-
- var credential = Assert.Single(list);
- Assert.Equal(accountId, credential.AccountId);
- Assert.Equal("prod", credential.AccountName);
- Assert.Equal("Adobe", credential.ProviderName);
- Assert.Equal("Production", credential.Environment);
- Assert.False(credential.IsSelected);
- }
+ // Simulate an import: drop it, then restore from the export record.
+ Assert.True(await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(id)));
+ await manager.RestoreCredentialAsync(exported);
- [Fact]
- public async Task ListCredentialsAsync_FiltersByProvider()
- {
- if (_skip) return;
- var manager = NewManager();
- _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
- _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
+ var restored = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1));
+ Assert.Equal(id, restored.AccountId);
+ Assert.Equal(exported.CreatedAt, restored.CreatedAt); // libsecret preserves it via attribute
+ Assert.True(restored.IsSelected);
+ Assert.Equal("{\"apiKey\":\"secret\"}", await manager.GetSelectedCredentialAsync("Adobe"));
+ }
- var adobe = (await manager.ListCredentialsAsync("Adobe")).ToList();
- var airtable = (await manager.ListCredentialsAsync("Airtable")).ToList();
+ [Fact]
+ public async Task AddCredentialAsync_ReturnsGuidAccountId()
+ {
+ if (_skip)
+ {
+ return;
+ }
- Assert.Single(adobe);
- Assert.Single(airtable);
- }
+ var manager = NewManager();
- [Fact]
- public async Task SelectAndGetSelected_RoundTripsDecryptedPayload()
- {
- if (_skip) return;
- var manager = NewManager();
- var payload = "{\"apiKey\":\"super-secret\"}";
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- Assert.True(await manager.SelectCredentialAsync(accountId));
- var selected = await manager.GetSelectedCredentialAsync("Adobe");
+ Assert.True(Guid.TryParse(accountId, out _));
+ }
- Assert.Equal(payload, selected);
- }
+ [Fact]
+ public async Task ListCredentialsAsync_ReturnsAddedCredential()
+ {
+ if (_skip)
+ {
+ return;
+ }
- [Fact]
- public async Task SelectCredentialAsync_ReturnsFalse_WhenNotFound()
- {
- if (_skip) return;
- var manager = NewManager();
+ var manager = NewManager();
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- var selected = await manager.SelectCredentialAsync(Guid.NewGuid().ToString());
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
- Assert.False(selected);
- }
+ var credential = Assert.Single(list);
+ Assert.Equal(accountId, credential.AccountId);
+ Assert.Equal("prod", credential.AccountName);
+ Assert.Equal("Adobe", credential.ProviderName);
+ Assert.Equal("Production", credential.Environment);
+ Assert.False(credential.IsSelected);
+ }
- [Fact]
- public async Task GetSelectedCredentialAsync_ReturnsNull_WhenNoneSelected()
- {
- if (_skip) return;
- var manager = NewManager();
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ [Fact]
+ public async Task ListCredentialsAsync_FiltersByProvider()
+ {
+ if (_skip)
+ {
+ return;
+ }
- var selected = await manager.GetSelectedCredentialAsync("Adobe");
+ var manager = NewManager();
+ _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
+ _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
- Assert.Null(selected);
- }
+ var adobe = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ var airtable = (await manager.ListCredentialsAsync("Airtable")).ToList();
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsDecryptedPayload_ForExistingAccount()
- {
- if (_skip) return;
- var manager = NewManager();
- var payload = "{\"apiKey\":\"super-secret\"}";
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
+ Assert.Single(adobe);
+ Assert.Single(airtable);
+ }
- var decrypted = await manager.GetCredentialByIdAsync("Adobe", accountId);
+ [Fact]
+ public async Task SelectAndGetSelected_RoundTripsDecryptedPayload()
+ {
+ if (_skip)
+ {
+ return;
+ }
- Assert.Equal(payload, decrypted);
- }
+ var manager = NewManager();
+ var payload = "{\"apiKey\":\"super-secret\"}";
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsNull_ForUnknownAccountId()
- {
- if (_skip) return;
- var manager = NewManager();
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ Assert.True(await RetryHelper.UntilTrueAsync(() => manager.SelectCredentialAsync(accountId)));
+ var selected = await manager.GetSelectedCredentialAsync("Adobe");
- var result = await manager.GetCredentialByIdAsync("Adobe", Guid.NewGuid().ToString());
+ Assert.Equal(payload, selected);
+ }
- Assert.Null(result);
- }
+ [Fact]
+ public async Task SelectCredentialAsync_ReturnsFalse_WhenNotFound()
+ {
+ if (_skip)
+ {
+ return;
+ }
- [Fact]
- public async Task GetCredentialByIdAsync_DoesNotMutateSelection()
- {
- if (_skip) return;
- var manager = NewManager();
- var selectedId = await manager.AddCredentialAsync("Adobe", "selected", "Production", "{\"a\":1}");
- var otherId = await manager.AddCredentialAsync("Adobe", "other", "Production", "{\"b\":2}");
- _ = await manager.SelectCredentialAsync(selectedId);
+ var manager = NewManager();
- _ = await manager.GetCredentialByIdAsync("Adobe", otherId);
+ var selected = await manager.SelectCredentialAsync(Guid.NewGuid().ToString());
- var listings = (await manager.ListCredentialsAsync("Adobe")).ToList();
- Assert.True(listings.Single(c => c.AccountId == selectedId).IsSelected);
- Assert.False(listings.Single(c => c.AccountId == otherId).IsSelected);
- }
+ Assert.False(selected);
+ }
- [Fact]
- public async Task GetCredentialByIdAsync_ReturnsNull_WhenProviderMismatches()
- {
- if (_skip) return;
- var manager = NewManager();
- var adobeId = await manager.AddCredentialAsync("Adobe", "adobe-acct", "Production", "{}");
+ [Fact]
+ public async Task GetSelectedCredentialAsync_ReturnsNull_WhenNoneSelected()
+ {
+ if (_skip)
+ {
+ return;
+ }
- var result = await manager.GetCredentialByIdAsync("Airtable", adobeId);
+ var manager = NewManager();
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- Assert.Null(result);
- }
+ var selected = await manager.GetSelectedCredentialAsync("Adobe");
- [Fact]
- public async Task DeleteCredentialAsync_RemovesCredential()
- {
- if (_skip) return;
- var manager = NewManager();
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ Assert.Null(selected);
+ }
- var deleted = await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(accountId));
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsDecryptedPayload_ForExistingAccount()
+ {
+ if (_skip)
+ {
+ return;
+ }
- Assert.True(deleted);
- Assert.Empty(list);
- }
+ var manager = NewManager();
+ var payload = "{\"apiKey\":\"super-secret\"}";
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", payload);
- [Fact]
- public async Task DeleteCredentialAsync_ClearsSelection_IfItWasSelected()
- {
- if (_skip) return;
- var manager = NewManager();
- var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- _ = await manager.SelectCredentialAsync(accountId);
+ var decrypted = await manager.GetCredentialByIdAsync("Adobe", accountId);
- _ = await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(accountId));
+ Assert.Equal(payload, decrypted);
+ }
- Assert.Null(await manager.GetSelectedCredentialAsync("Adobe"));
- }
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsNull_ForUnknownAccountId()
+ {
+ if (_skip)
+ {
+ return;
+ }
- [Fact]
- public async Task DeleteCredentialAsync_ReturnsFalse_WhenNotFound()
- {
- if (_skip) return;
- var manager = NewManager();
+ var manager = NewManager();
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
- var deleted = await manager.DeleteCredentialAsync(Guid.NewGuid().ToString());
+ var result = await manager.GetCredentialByIdAsync("Adobe", Guid.NewGuid().ToString());
- Assert.False(deleted);
- }
+ Assert.Null(result);
+ }
- [Fact]
- public async Task GetProviderNamesAsync_ReturnsDistinctProviders()
- {
- if (_skip) return;
- var manager = NewManager();
- _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
- _ = await manager.AddCredentialAsync("Adobe", "a2", "Sandbox", "{}");
- _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
+ [Fact]
+ public async Task GetCredentialByIdAsync_DoesNotMutateSelection()
+ {
+ if (_skip)
+ {
+ return;
+ }
- var names = (await manager.GetProviderNamesAsync()).ToList();
+ var manager = NewManager();
+ var selectedId = await manager.AddCredentialAsync("Adobe", "selected", "Production", "{\"a\":1}");
+ var otherId = await manager.AddCredentialAsync("Adobe", "other", "Production", "{\"b\":2}");
+ _ = await manager.SelectCredentialAsync(selectedId);
- Assert.Equal(2, names.Count);
- Assert.Contains("Adobe", names);
- Assert.Contains("Airtable", names);
- }
+ _ = await manager.GetCredentialByIdAsync("Adobe", otherId);
- [Fact]
- public async Task Credentials_AreIsolated_ByAppIdentifier()
- {
- if (_skip) return;
+ var listings = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ Assert.True(listings.Single(c => c.AccountId == selectedId).IsSelected);
+ Assert.False(listings.Single(c => c.AccountId == otherId).IsSelected);
+ }
- var neighbourIdentifier = $"test.nextiteration.sca.neighbour.{Guid.NewGuid():N}";
-#pragma warning disable CA1416
- var neighbour = new LibsecretCredentialManager(neighbourIdentifier, collection: TestCollection);
-#pragma warning restore CA1416
- try
+ [Fact]
+ public async Task GetCredentialByIdAsync_ReturnsNull_WhenProviderMismatches()
+ {
+ if (_skip)
+ {
+ return;
+ }
+
+ var manager = NewManager();
+ var adobeId = await manager.AddCredentialAsync("Adobe", "adobe-acct", "Production", "{}");
+
+ var result = await manager.GetCredentialByIdAsync("Airtable", adobeId);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task DeleteCredentialAsync_RemovesCredential()
+ {
+ if (_skip)
+ {
+ return;
+ }
+
+ var manager = NewManager();
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+
+ var deleted = await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(accountId));
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+
+ Assert.True(deleted);
+ Assert.Empty(list);
+ }
+
+ [Fact]
+ public async Task DeleteCredentialAsync_ClearsSelection_IfItWasSelected()
+ {
+ if (_skip)
+ {
+ return;
+ }
+
+ var manager = NewManager();
+ var accountId = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}");
+ _ = await manager.SelectCredentialAsync(accountId);
+
+ _ = await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(accountId));
+
+ Assert.Null(await manager.GetSelectedCredentialAsync("Adobe"));
+ }
+
+ [Fact]
+ public async Task DeleteCredentialAsync_ReturnsFalse_WhenNotFound()
+ {
+ if (_skip)
+ {
+ return;
+ }
+
+ var manager = NewManager();
+
+ var deleted = await manager.DeleteCredentialAsync(Guid.NewGuid().ToString());
+
+ Assert.False(deleted);
+ }
+
+ [Fact]
+ public async Task GetProviderNamesAsync_ReturnsDistinctProviders()
{
- var us = NewManager();
- _ = await us.AddCredentialAsync("Adobe", "ours", "Production", "{}");
+ if (_skip)
+ {
+ return;
+ }
- var neighbourList = (await neighbour.ListCredentialsAsync("Adobe")).ToList();
- Assert.Empty(neighbourList);
+ var manager = NewManager();
+ _ = await manager.AddCredentialAsync("Adobe", "a1", "Production", "{}");
+ _ = await manager.AddCredentialAsync("Adobe", "a2", "Sandbox", "{}");
+ _ = await manager.AddCredentialAsync("Airtable", "b1", "Production", "{}");
+
+ var names = (await manager.GetProviderNamesAsync()).ToList();
+
+ Assert.Equal(2, names.Count);
+ Assert.Contains("Adobe", names);
+ Assert.Contains("Airtable", names);
}
- finally
+
+ [Fact]
+ public async Task Credentials_AreIsolated_ByAppIdentifier()
{
- foreach (var p in await neighbour.GetProviderNamesAsync())
+ if (_skip)
{
- foreach (var s in await neighbour.ListCredentialsAsync(p))
+ return;
+ }
+
+ var neighbourIdentifier = $"test.nextiteration.sca.neighbour.{Guid.NewGuid():N}";
+#pragma warning disable CA1416
+ var neighbour = new LibsecretCredentialManager(neighbourIdentifier, collection: TestCollection);
+#pragma warning restore CA1416
+ try
+ {
+ var us = NewManager();
+ _ = await us.AddCredentialAsync("Adobe", "ours", "Production", "{}");
+
+ var neighbourList = (await neighbour.ListCredentialsAsync("Adobe")).ToList();
+ Assert.Empty(neighbourList);
+ }
+ finally
+ {
+ foreach (var p in await neighbour.GetProviderNamesAsync())
{
- _ = await neighbour.DeleteCredentialAsync(s.AccountId);
+ foreach (var s in await neighbour.ListCredentialsAsync(p))
+ {
+ _ = await neighbour.DeleteCredentialAsync(s.AccountId);
+ }
}
}
}
- }
- [Theory]
- [InlineData("")]
- [InlineData(" ")]
- [InlineData("../etc/passwd")]
- [InlineData("pro*vider")]
- [InlineData("pro vider")]
- public async Task AddCredentialAsync_InvalidProviderName_Throws(string providerName)
- {
- if (_skip) return;
- var manager = NewManager();
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("../etc/passwd")]
+ [InlineData("pro*vider")]
+ [InlineData("pro vider")]
+ public async Task AddCredentialAsync_InvalidProviderName_Throws(string providerName)
+ {
+ if (_skip)
+ {
+ return;
+ }
- await Assert.ThrowsAnyAsync(
- () => manager.AddCredentialAsync(providerName, "name", "Production", "{}"));
- }
+ var manager = NewManager();
- [Fact]
- public void Constructor_NullAppIdentifier_Throws()
- {
- if (!OperatingSystem.IsLinux()) return;
+ await Assert.ThrowsAnyAsync(
+ () => manager.AddCredentialAsync(providerName, "name", "Production", "{}"));
+ }
+
+ [Fact]
+ public void Constructor_NullAppIdentifier_Throws()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
#pragma warning disable CA1416
- Assert.ThrowsAny(() => new LibsecretCredentialManager(null!));
+ Assert.ThrowsAny(() => new LibsecretCredentialManager(null!));
#pragma warning restore CA1416
- }
+ }
- [Fact]
- public async Task ListCredentialsAsync_IncludesDisplayFields_FromSummaryProvider()
- {
- if (_skip) return;
- var summaryProvider = new FakeAdobeSummaryProvider();
- var manager = NewManager([summaryProvider]);
+ [Fact]
+ public async Task ListCredentialsAsync_IncludesDisplayFields_FromSummaryProvider()
+ {
+ if (_skip)
+ {
+ return;
+ }
- _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"xyz\"}");
- var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
+ var summaryProvider = new FakeAdobeSummaryProvider();
+ var manager = NewManager([summaryProvider]);
- var credential = Assert.Single(list);
- var field = Assert.Single(credential.DisplayFields);
- Assert.Equal("Fingerprint", field.Key);
- Assert.Equal("xyz", field.Value);
- }
+ _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"xyz\"}");
+ var list = (await manager.ListCredentialsAsync("Adobe")).ToList();
- private sealed class FakeAdobeSummaryProvider : ICredentialSummaryProvider
- {
- public string ProviderName => "Adobe";
+ var credential = Assert.Single(list);
+ var field = Assert.Single(credential.DisplayFields);
+ Assert.Equal("Fingerprint", field.Key);
+ Assert.Equal("xyz", field.Value);
+ }
- public IReadOnlyList> GetDisplayFields(string decryptedCredentialJson)
+ private sealed class FakeAdobeSummaryProvider : ICredentialSummaryProvider
{
- const string token = "\"apiKey\":\"";
- var start = decryptedCredentialJson.IndexOf(token, StringComparison.Ordinal);
- if (start < 0) return [];
- start += token.Length;
- var end = decryptedCredentialJson.IndexOf('"', start);
- var value = decryptedCredentialJson[start..end];
- return [new("Fingerprint", value)];
+ public string ProviderName => "Adobe";
+
+ public IReadOnlyList> GetDisplayFields(string decryptedCredentialJson)
+ {
+ const string token = "\"apiKey\":\"";
+ var start = decryptedCredentialJson.IndexOf(token, StringComparison.Ordinal);
+ if (start < 0)
+ {
+ return [];
+ }
+
+ start += token.Length;
+ var end = decryptedCredentialJson.IndexOf('"', start);
+ var value = decryptedCredentialJson[start..end];
+ return [new("Fingerprint", value)];
+ }
}
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/SelectionsLockTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/SelectionsLockTests.cs
index a8b5262..2938790 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/SelectionsLockTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/SelectionsLockTests.cs
@@ -3,59 +3,60 @@
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Persistence;
-
-public sealed class SelectionsLockTests
+namespace NextIteration.SpectreConsole.Auth.Tests.Persistence
{
- [Fact]
- public async Task Acquire_WhenUncontended_Succeeds()
- {
- using var temp = new TempDir();
- var lockPath = Path.Join(temp.Path, "selections.json.lock");
-
- using var held = await SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken);
-
- // Successful acquisition is the assertion; we'd hit the IOException
- // path below if the contract were broken.
- Assert.NotNull(held);
- }
-
- [Fact]
- public async Task Acquire_WhenHeld_RetriesUntilReleased()
- {
- using var temp = new TempDir();
- var lockPath = Path.Join(temp.Path, "selections.json.lock");
-
- var first = await SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken);
-
- // Kick off a contender — it should park inside the backoff loop until
- // we dispose the holder, then proceed.
- var contender = SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken);
-
- // Give the contender a moment to land inside its retry loop, then
- // release. If the lock semantics are broken the contender would have
- // already completed by now.
- await Task.Delay(50, TestContext.Current.CancellationToken);
- Assert.False(contender.IsCompleted, "contender completed while lock was held");
-
- first.Dispose();
-
- using var second = await contender.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
- Assert.NotNull(second);
- }
-
- [Fact]
- public async Task Acquire_AfterRelease_Succeeds()
+ public sealed class SelectionsLockTests
{
- using var temp = new TempDir();
- var lockPath = Path.Join(temp.Path, "selections.json.lock");
-
- (await SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken)).Dispose();
-
- // DeleteOnClose should clean up the sentinel file when the holder
- // disposes; a fresh acquirer must succeed without seeing a stale
- // lock.
- using var second = await SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken);
- Assert.NotNull(second);
+ [Fact]
+ public async Task Acquire_WhenUncontended_Succeeds()
+ {
+ using var temp = new TempDir();
+ var lockPath = Path.Join(temp.Path, "selections.json.lock");
+
+ using var held = await SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken);
+
+ // Successful acquisition is the assertion; we'd hit the IOException
+ // path below if the contract were broken.
+ Assert.NotNull(held);
+ }
+
+ [Fact]
+ public async Task Acquire_WhenHeld_RetriesUntilReleased()
+ {
+ using var temp = new TempDir();
+ var lockPath = Path.Join(temp.Path, "selections.json.lock");
+
+ var first = await SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken);
+
+ // Kick off a contender — it should park inside the backoff loop until
+ // we dispose the holder, then proceed.
+ var contender = SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken);
+
+ // Give the contender a moment to land inside its retry loop, then
+ // release. If the lock semantics are broken the contender would have
+ // already completed by now.
+ await Task.Delay(50, TestContext.Current.CancellationToken);
+ Assert.False(contender.IsCompleted, "contender completed while lock was held");
+
+ first.Dispose();
+
+ using var second = await contender.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ Assert.NotNull(second);
+ }
+
+ [Fact]
+ public async Task Acquire_AfterRelease_Succeeds()
+ {
+ using var temp = new TempDir();
+ var lockPath = Path.Join(temp.Path, "selections.json.lock");
+
+ (await SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken)).Dispose();
+
+ // DeleteOnClose should clean up the sentinel file when the holder
+ // disposes; a fresh acquirer must succeed without seeing a stale
+ // lock.
+ using var second = await SelectionsLock.AcquireAsync(lockPath, TestContext.Current.CancellationToken);
+ Assert.NotNull(second);
+ }
}
}
diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Portability/CredentialArchiveTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Portability/CredentialArchiveTests.cs
index ca4b60d..0f686d9 100644
--- a/tests/NextIteration.SpectreConsole.Auth.Tests/Portability/CredentialArchiveTests.cs
+++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Portability/CredentialArchiveTests.cs
@@ -5,101 +5,102 @@
using Xunit;
-namespace NextIteration.SpectreConsole.Auth.Tests.Portability;
-
-public sealed class CredentialArchiveTests
+namespace NextIteration.SpectreConsole.Auth.Tests.Portability
{
- private const string Passphrase = "correct horse battery staple";
-
- private static CredentialExport Sample(string accountName, string payload, bool selected = false) => new()
+ public sealed class CredentialArchiveTests
{
- AccountId = Guid.NewGuid().ToString(),
- AccountName = accountName,
- ProviderName = "Adobe",
- Environment = "Production",
- CredentialData = payload,
- CreatedAt = new DateTime(2021, 6, 7, 8, 9, 10, DateTimeKind.Utc),
- IsSelected = selected,
- };
-
- [Fact]
- public void Serialize_Deserialize_RoundTripsAllFields()
- {
- var input = new List
+ private const string Passphrase = "correct horse battery staple";
+
+ private static CredentialExport Sample(string accountName, string payload, bool selected = false) => new()
{
- Sample("prod", "{\"apiKey\":\"one\"}", selected: true),
- Sample("sandbox", "{\"apiKey\":\"two\"}"),
+ AccountId = Guid.NewGuid().ToString(),
+ AccountName = accountName,
+ ProviderName = "Adobe",
+ Environment = "Production",
+ CredentialData = payload,
+ CreatedAt = new DateTime(2021, 6, 7, 8, 9, 10, DateTimeKind.Utc),
+ IsSelected = selected,
};
- var bundle = CredentialArchive.Serialize(input, Passphrase);
- var output = CredentialArchive.Deserialize(bundle, Passphrase);
-
- Assert.Equal(input.Count, output.Count);
- for (var i = 0; i < input.Count; i++)
+ [Fact]
+ public void Serialize_Deserialize_RoundTripsAllFields()
{
- Assert.Equal(input[i].AccountId, output[i].AccountId);
- Assert.Equal(input[i].AccountName, output[i].AccountName);
- Assert.Equal(input[i].ProviderName, output[i].ProviderName);
- Assert.Equal(input[i].Environment, output[i].Environment);
- Assert.Equal(input[i].CredentialData, output[i].CredentialData);
- Assert.Equal(input[i].CreatedAt, output[i].CreatedAt);
- Assert.Equal(input[i].IsSelected, output[i].IsSelected);
+ var input = new List
+ {
+ Sample("prod", "{\"apiKey\":\"one\"}", selected: true),
+ Sample("sandbox", "{\"apiKey\":\"two\"}"),
+ };
+
+ var bundle = CredentialArchive.Serialize(input, Passphrase);
+ var output = CredentialArchive.Deserialize(bundle, Passphrase);
+
+ Assert.Equal(input.Count, output.Count);
+ for (var i = 0; i < input.Count; i++)
+ {
+ Assert.Equal(input[i].AccountId, output[i].AccountId);
+ Assert.Equal(input[i].AccountName, output[i].AccountName);
+ Assert.Equal(input[i].ProviderName, output[i].ProviderName);
+ Assert.Equal(input[i].Environment, output[i].Environment);
+ Assert.Equal(input[i].CredentialData, output[i].CredentialData);
+ Assert.Equal(input[i].CreatedAt, output[i].CreatedAt);
+ Assert.Equal(input[i].IsSelected, output[i].IsSelected);
+ }
}
- }
- [Fact]
- public void Serialize_Deserialize_EmptySet_RoundTrips()
- {
- var bundle = CredentialArchive.Serialize([], Passphrase);
- var output = CredentialArchive.Deserialize(bundle, Passphrase);
- Assert.Empty(output);
- }
+ [Fact]
+ public void Serialize_Deserialize_EmptySet_RoundTrips()
+ {
+ var bundle = CredentialArchive.Serialize([], Passphrase);
+ var output = CredentialArchive.Deserialize(bundle, Passphrase);
+ Assert.Empty(output);
+ }
- [Fact]
- public void Deserialize_WrongPassphrase_Throws()
- {
- var bundle = CredentialArchive.Serialize([Sample("prod", "{}")], Passphrase);
+ [Fact]
+ public void Deserialize_WrongPassphrase_Throws()
+ {
+ var bundle = CredentialArchive.Serialize([Sample("prod", "{}")], Passphrase);
- var ex = Assert.Throws