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( - () => CredentialArchive.Deserialize(bundle, "not the passphrase")); - Assert.Contains("passphrase", ex.Message, StringComparison.OrdinalIgnoreCase); - } + var ex = Assert.Throws( + () => CredentialArchive.Deserialize(bundle, "not the passphrase")); + Assert.Contains("passphrase", ex.Message, StringComparison.OrdinalIgnoreCase); + } - [Fact] - public void Deserialize_TamperedPayload_Throws() - { - var bundle = CredentialArchive.Serialize([Sample("prod", "{\"apiKey\":\"secret\"}")], Passphrase); - - // Flip a byte inside the encrypted payload; AES-GCM's tag check must - // reject it rather than returning garbage plaintext. - var node = JsonNode.Parse(bundle)!; - var payload = Convert.FromBase64String(node["payload"]!.GetValue()); - payload[^1] ^= 0xFF; - node["payload"] = Convert.ToBase64String(payload); - var tampered = node.ToJsonString(); - - _ = Assert.Throws( - () => CredentialArchive.Deserialize(tampered, Passphrase)); - } + [Fact] + public void Deserialize_TamperedPayload_Throws() + { + var bundle = CredentialArchive.Serialize([Sample("prod", "{\"apiKey\":\"secret\"}")], Passphrase); + + // Flip a byte inside the encrypted payload; AES-GCM's tag check must + // reject it rather than returning garbage plaintext. + var node = JsonNode.Parse(bundle)!; + var payload = Convert.FromBase64String(node["payload"]!.GetValue()); + payload[^1] ^= 0xFF; + node["payload"] = Convert.ToBase64String(payload); + var tampered = node.ToJsonString(); + + _ = Assert.Throws( + () => CredentialArchive.Deserialize(tampered, Passphrase)); + } - [Fact] - public void Deserialize_NotAnArchive_Throws() - { - var ex = Assert.Throws( - () => CredentialArchive.Deserialize("this is not json", Passphrase)); - Assert.Contains("archive", ex.Message, StringComparison.OrdinalIgnoreCase); - } + [Fact] + public void Deserialize_NotAnArchive_Throws() + { + var ex = Assert.Throws( + () => CredentialArchive.Deserialize("this is not json", Passphrase)); + Assert.Contains("archive", ex.Message, StringComparison.OrdinalIgnoreCase); + } - [Fact] - public void Deserialize_UnsupportedVersion_Throws() - { - var bundle = CredentialArchive.Serialize([Sample("prod", "{}")], Passphrase); - var node = JsonNode.Parse(bundle)!; - node["version"] = 999; - var future = node.ToJsonString(); - - var ex = Assert.Throws( - () => CredentialArchive.Deserialize(future, Passphrase)); - Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase); + [Fact] + public void Deserialize_UnsupportedVersion_Throws() + { + var bundle = CredentialArchive.Serialize([Sample("prod", "{}")], Passphrase); + var node = JsonNode.Parse(bundle)!; + node["version"] = 999; + var future = node.ToJsonString(); + + var ex = Assert.Throws( + () => CredentialArchive.Deserialize(future, Passphrase)); + Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase); + } } } diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Portability/CredentialPortabilityServiceTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Portability/CredentialPortabilityServiceTests.cs index c91ace3..0c4e5ca 100644 --- a/tests/NextIteration.SpectreConsole.Auth.Tests/Portability/CredentialPortabilityServiceTests.cs +++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Portability/CredentialPortabilityServiceTests.cs @@ -5,153 +5,154 @@ using Xunit; -namespace NextIteration.SpectreConsole.Auth.Tests.Portability; - -public sealed class CredentialPortabilityServiceTests +namespace NextIteration.SpectreConsole.Auth.Tests.Portability { - private const string Passphrase = "move-me-between-machines"; + public sealed class CredentialPortabilityServiceTests + { + private const string Passphrase = "move-me-between-machines"; - private static FileCredentialManager NewManager(string directory) => - new(new LocalFileCredentialEncryption(directory), directory); + private static FileCredentialManager NewManager(string directory) => + new(new LocalFileCredentialEncryption(directory), directory); - private static async Task SingleExportAsync(FileCredentialManager manager) => - (await manager.ExportCredentialsAsync()).Single(); + private static async Task SingleExportAsync(FileCredentialManager manager) => + (await manager.ExportCredentialsAsync()).Single(); - [Fact] - public async Task ExportThenImport_RoundTripsAllFields_IntoFreshStore() - { - using var source = new TempDir(); - using var target = new TempDir(); + [Fact] + public async Task ExportThenImport_RoundTripsAllFields_IntoFreshStore() + { + using var source = new TempDir(); + using var target = new TempDir(); - var sourceManager = NewManager(source.Path); - var adobeId = await sourceManager.AddCredentialAsync("Adobe", "prod", "Production", "{\"k\":\"a\"}"); - _ = await sourceManager.AddCredentialAsync("Adobe", "sandbox", "Sandbox", "{\"k\":\"b\"}"); - _ = await sourceManager.AddCredentialAsync("Airtable", "main", "Production", "{\"k\":\"c\"}"); - Assert.True(await sourceManager.SelectCredentialAsync(adobeId)); + var sourceManager = NewManager(source.Path); + var adobeId = await sourceManager.AddCredentialAsync("Adobe", "prod", "Production", "{\"k\":\"a\"}"); + _ = await sourceManager.AddCredentialAsync("Adobe", "sandbox", "Sandbox", "{\"k\":\"b\"}"); + _ = await sourceManager.AddCredentialAsync("Airtable", "main", "Production", "{\"k\":\"c\"}"); + Assert.True(await sourceManager.SelectCredentialAsync(adobeId)); - var exportService = new CredentialPortabilityService(sourceManager); - var export = await exportService.ExportAsync(Passphrase); - Assert.Equal(3, export.Count); + var exportService = new CredentialPortabilityService(sourceManager); + var export = await exportService.ExportAsync(Passphrase); + Assert.Equal(3, export.Count); - var targetManager = NewManager(target.Path); - var importService = new CredentialPortabilityService(targetManager); - var result = await importService.ImportAsync(export.Bundle, Passphrase, (_, _) => ConflictResolution.Skip); + var targetManager = NewManager(target.Path); + var importService = new CredentialPortabilityService(targetManager); + var result = await importService.ImportAsync(export.Bundle, Passphrase, (_, _) => ConflictResolution.Skip); - Assert.Equal(3, result.Added); - Assert.Equal(0, result.Overwritten); - Assert.Equal(0, result.Skipped); + Assert.Equal(3, result.Added); + Assert.Equal(0, result.Overwritten); + Assert.Equal(0, result.Skipped); - var expected = (await sourceManager.ExportCredentialsAsync()).OrderBy(c => c.AccountId).ToList(); - var actual = (await targetManager.ExportCredentialsAsync()).OrderBy(c => c.AccountId).ToList(); + var expected = (await sourceManager.ExportCredentialsAsync()).OrderBy(c => c.AccountId).ToList(); + var actual = (await targetManager.ExportCredentialsAsync()).OrderBy(c => c.AccountId).ToList(); - Assert.Equal(expected.Count, actual.Count); - for (var i = 0; i < expected.Count; i++) - { - Assert.Equal(expected[i].AccountId, actual[i].AccountId); - Assert.Equal(expected[i].AccountName, actual[i].AccountName); - Assert.Equal(expected[i].ProviderName, actual[i].ProviderName); - Assert.Equal(expected[i].Environment, actual[i].Environment); - Assert.Equal(expected[i].CredentialData, actual[i].CredentialData); - Assert.Equal(expected[i].CreatedAt, actual[i].CreatedAt); - Assert.Equal(expected[i].IsSelected, actual[i].IsSelected); + Assert.Equal(expected.Count, actual.Count); + for (var i = 0; i < expected.Count; i++) + { + Assert.Equal(expected[i].AccountId, actual[i].AccountId); + Assert.Equal(expected[i].AccountName, actual[i].AccountName); + Assert.Equal(expected[i].ProviderName, actual[i].ProviderName); + Assert.Equal(expected[i].Environment, actual[i].Environment); + Assert.Equal(expected[i].CredentialData, actual[i].CredentialData); + Assert.Equal(expected[i].CreatedAt, actual[i].CreatedAt); + Assert.Equal(expected[i].IsSelected, actual[i].IsSelected); + } + + // The selection followed the credential across the import. + var selected = actual.Single(c => c.IsSelected); + Assert.Equal("prod", selected.AccountName); + Assert.Equal("{\"k\":\"a\"}", await targetManager.GetSelectedCredentialAsync("Adobe")); } - // The selection followed the credential across the import. - var selected = actual.Single(c => c.IsSelected); - Assert.Equal("prod", selected.AccountName); - Assert.Equal("{\"k\":\"a\"}", await targetManager.GetSelectedCredentialAsync("Adobe")); - } - - [Fact] - public async Task Import_Skip_IsIdempotent() - { - using var source = new TempDir(); - using var target = new TempDir(); + [Fact] + public async Task Import_Skip_IsIdempotent() + { + using var source = new TempDir(); + using var target = new TempDir(); - var sourceManager = NewManager(source.Path); - _ = await sourceManager.AddCredentialAsync("Adobe", "prod", "Production", "{\"k\":\"a\"}"); - var bundle = (await new CredentialPortabilityService(sourceManager).ExportAsync(Passphrase)).Bundle; + var sourceManager = NewManager(source.Path); + _ = await sourceManager.AddCredentialAsync("Adobe", "prod", "Production", "{\"k\":\"a\"}"); + var bundle = (await new CredentialPortabilityService(sourceManager).ExportAsync(Passphrase)).Bundle; - var targetManager = NewManager(target.Path); - var importService = new CredentialPortabilityService(targetManager); + var targetManager = NewManager(target.Path); + var importService = new CredentialPortabilityService(targetManager); - var first = await importService.ImportAsync(bundle, Passphrase, (_, _) => ConflictResolution.Skip); - Assert.Equal(1, first.Added); + var first = await importService.ImportAsync(bundle, Passphrase, (_, _) => ConflictResolution.Skip); + Assert.Equal(1, first.Added); - var second = await importService.ImportAsync(bundle, Passphrase, (_, _) => ConflictResolution.Skip); - Assert.Equal(0, second.Added); - Assert.Equal(1, second.Skipped); + var second = await importService.ImportAsync(bundle, Passphrase, (_, _) => ConflictResolution.Skip); + Assert.Equal(0, second.Added); + Assert.Equal(1, second.Skipped); - Assert.Single(await targetManager.ExportCredentialsAsync()); - } + Assert.Single(await targetManager.ExportCredentialsAsync()); + } - [Fact] - public async Task Import_Overwrite_ReplacesMatchingCredential() - { - using var target = new TempDir(); - var targetManager = NewManager(target.Path); + [Fact] + public async Task Import_Overwrite_ReplacesMatchingCredential() + { + using var target = new TempDir(); + var targetManager = NewManager(target.Path); - // Pre-existing credential the incoming one will collide with. - _ = await targetManager.AddCredentialAsync("Adobe", "prod", "Production", "{\"k\":\"OLD\"}"); + // Pre-existing credential the incoming one will collide with. + _ = await targetManager.AddCredentialAsync("Adobe", "prod", "Production", "{\"k\":\"OLD\"}"); - // Craft an incoming archive with the same identity (provider/name/env) - // but a different account id and payload. - var incoming = new CredentialExport - { - AccountId = Guid.NewGuid().ToString(), - AccountName = "prod", - ProviderName = "Adobe", - Environment = "Production", - CredentialData = "{\"k\":\"NEW\"}", - CreatedAt = new DateTime(2019, 3, 4, 5, 6, 7, DateTimeKind.Utc), - IsSelected = true, - }; - var bundle = CredentialArchive.Serialize([incoming], Passphrase); - - var conflictSeen = 0; - var result = await new CredentialPortabilityService(targetManager) - .ImportAsync(bundle, Passphrase, (inc, existing) => + // Craft an incoming archive with the same identity (provider/name/env) + // but a different account id and payload. + var incoming = new CredentialExport { - conflictSeen++; - Assert.Equal("prod", inc.AccountName); - Assert.Equal("{\"k\":\"OLD\"}", existing.CredentialData); - return ConflictResolution.Overwrite; - }); - - Assert.Equal(1, conflictSeen); - Assert.Equal(1, result.Overwritten); - Assert.Equal(0, result.Added); - - var stored = await SingleExportAsync(targetManager); - Assert.Equal(incoming.AccountId, stored.AccountId); - Assert.Equal("{\"k\":\"NEW\"}", stored.CredentialData); - Assert.True(stored.IsSelected); - } - - [Fact] - public async Task Import_AddsWhenNoConflict_WithoutInvokingResolver() - { - using var target = new TempDir(); - var targetManager = NewManager(target.Path); - _ = await targetManager.AddCredentialAsync("Adobe", "prod", "Production", "{\"k\":\"a\"}"); + AccountId = Guid.NewGuid().ToString(), + AccountName = "prod", + ProviderName = "Adobe", + Environment = "Production", + CredentialData = "{\"k\":\"NEW\"}", + CreatedAt = new DateTime(2019, 3, 4, 5, 6, 7, DateTimeKind.Utc), + IsSelected = true, + }; + var bundle = CredentialArchive.Serialize([incoming], Passphrase); + + var conflictSeen = 0; + var result = await new CredentialPortabilityService(targetManager) + .ImportAsync(bundle, Passphrase, (inc, existing) => + { + conflictSeen++; + Assert.Equal("prod", inc.AccountName); + Assert.Equal("{\"k\":\"OLD\"}", existing.CredentialData); + return ConflictResolution.Overwrite; + }); + + Assert.Equal(1, conflictSeen); + Assert.Equal(1, result.Overwritten); + Assert.Equal(0, result.Added); + + var stored = await SingleExportAsync(targetManager); + Assert.Equal(incoming.AccountId, stored.AccountId); + Assert.Equal("{\"k\":\"NEW\"}", stored.CredentialData); + Assert.True(stored.IsSelected); + } - var incoming = new CredentialExport + [Fact] + public async Task Import_AddsWhenNoConflict_WithoutInvokingResolver() { - AccountId = Guid.NewGuid().ToString(), - AccountName = "sandbox", // different name → no collision - ProviderName = "Adobe", - Environment = "Sandbox", - CredentialData = "{\"k\":\"b\"}", - CreatedAt = new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), - IsSelected = false, - }; - var bundle = CredentialArchive.Serialize([incoming], Passphrase); - - var result = await new CredentialPortabilityService(targetManager) - .ImportAsync(bundle, Passphrase, (_, _) => throw new Xunit.Sdk.XunitException("resolver must not run without a conflict")); - - Assert.Equal(1, result.Added); - Assert.Equal(0, result.Overwritten); - Assert.Equal(2, (await targetManager.ExportCredentialsAsync()).Count); + using var target = new TempDir(); + var targetManager = NewManager(target.Path); + _ = await targetManager.AddCredentialAsync("Adobe", "prod", "Production", "{\"k\":\"a\"}"); + + var incoming = new CredentialExport + { + AccountId = Guid.NewGuid().ToString(), + AccountName = "sandbox", // different name → no collision + ProviderName = "Adobe", + Environment = "Sandbox", + CredentialData = "{\"k\":\"b\"}", + CreatedAt = new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), + IsSelected = false, + }; + var bundle = CredentialArchive.Serialize([incoming], Passphrase); + + var result = await new CredentialPortabilityService(targetManager) + .ImportAsync(bundle, Passphrase, (_, _) => throw new Xunit.Sdk.XunitException("resolver must not run without a conflict")); + + Assert.Equal(1, result.Added); + Assert.Equal(0, result.Overwritten); + Assert.Equal(2, (await targetManager.ExportCredentialsAsync()).Count); + } } }