From 4f3268aca24799773faf6df7779713e2fe6e5ccb Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Tue, 13 Oct 2026 00:48:50 +0000 Subject: [PATCH] =?UTF-8?q?release:=202.3.0=20=E2=80=94=20accounts=20info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `accounts info`, which shows where and how credentials are stored: backend, credentials directory / keystore / selections paths, encryption (AES-256-GCM, DPAPI, or a custom type's name), whether AdditionalEntropy is set (presence only), and the directory's permissions, warning when they reach beyond the owner. Keychain and libsecret show their app identifier (and collection). It describes the resolved ICredentialManager rather than CredentialStoreOptions, so it is right for the README's hand-registered DPAPI store and exposes a custom ICredentialEncryption registered in the wrong order. It never decrypts or queries an OS secret store, so it cannot prompt. Values are written unwrapped so long paths copy-paste. Minor: a new public command type, no provider-facing interface change. Bumps Version to 2.3.0 and cuts the CHANGELOG section. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 ++- README.md | 4 +- .../CommandConfiguratorExtensions.cs | 7 +- .../Commands/CredentialStoreDescriber.cs | 157 ++++++++++++++++++ .../Commands/CredentialStoreInfoCommand.cs | 67 ++++++++ .../LocalFileCredentialEncryption.cs | 9 + .../NextIteration.SpectreConsole.Auth.csproj | 2 +- .../Persistence/FileCredentialManager.cs | 9 + .../Keychain/KeychainCredentialManager.cs | 3 + .../Libsecret/LibsecretCredentialManager.cs | 6 + .../Commands/CredentialStoreDescriberTests.cs | 149 +++++++++++++++++ 11 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreDescriber.cs create mode 100644 src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreInfoCommand.cs create mode 100644 tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CredentialStoreDescriberTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d9503ae..77938ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.0] — 2026-10-13 + +Adds **`accounts info`**, which shows where and how credentials are stored. It is a new +command in the `accounts` branch and needs no code change in a consuming CLI: anything +calling `AddAccountsBranch()` gets it on upgrade. + +No provider-facing interface changed, so the provider packages need no re-cut: their +`[2.2.0, 3.0.0)` range admits 2.3.0. A range resolves to its floor, though, so a CLI that +only references the providers keeps getting 2.2.0 until it references 2.3.0 directly. + +### Added + +- **`accounts info`** — shows where and how credentials are stored, so a user can find the + credentials directory without knowing how the host CLI configured it, and a developer can + confirm what is really wired up. For the file backend it lists the credentials directory, + keystore and selections paths, the encryption (AES-256-GCM, Windows DPAPI, or the type name + of a custom `ICredentialEncryption`), whether `AdditionalEntropy` is set (presence only, + never the value), and the directory's permissions — warning when they reach beyond the + owner, since those permissions are the default mode's security boundary. For Keychain and + libsecret it lists the app identifier (and collection). It describes the resolved + `ICredentialManager` rather than `CredentialStoreOptions`, so it is accurate for a + hand-registered DPAPI store and exposes a custom encryption registered in the wrong order. + It never decrypts a credential or queries an OS secret store, so it cannot prompt. + ## [2.2.0] — 2026-10-06 Moves to **Spectre.Console.Cli 0.57.2**. Spectre.Console.Cli now ships on its own release @@ -952,7 +976,8 @@ Consumers needed a way to read a specific stored credential's secret at runtime - SourceLink, deterministic builds, embedded symbols, published symbol packages. - `TreatWarningsAsErrors=true`, `AnalysisLevel=latest` — zero-warning public API. -[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/compare/v2.2.0...HEAD +[Unreleased]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/compare/v2.3.0...HEAD +[2.3.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v2.3.0 [2.2.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v2.2.0 [2.1.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v2.1.0 [2.0.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Auth/releases/tag/v2.0.0 diff --git a/README.md b/README.md index ce60386..a1562e4 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ public sealed class SyncCommand(AdobeAuthenticationService auth) : AsyncCommand | `accounts delete [id] [--force]` | Remove a credential. Clears the selection if it pointed at the deleted entry. | | `accounts export [--force]` | Write every credential to a single passphrase-encrypted archive. | | `accounts import [--on-conflict skip\|overwrite]` | Restore credentials from an archive into this machine's store. | +| `accounts info` | Show where and how credentials are stored: the backend, the credentials directory, keystore and selections paths, the encryption in use (AES-GCM file, DPAPI, Keychain, libsecret or a custom type), whether `AdditionalEntropy` is set, and the directory's permissions. Never decrypts anything or touches an OS secret store. | Every command accepts `-v` / `--verbose` for full stack-trace output when something goes wrong. @@ -424,7 +425,8 @@ Order matters and getting it wrong fails **silently**. `AddCredentialStore` regi a custom backend registered *first* is quietly ignored and your credentials are encrypted with the default one. There is no error to tell you, which is exactly the wrong failure mode for a change made to strengthen encryption. Register after, and verify with -`provider.GetRequiredService().GetType()` if in doubt. +`provider.GetRequiredService().GetType()` if in doubt — or run +`accounts info`, which names the encryption type the store is actually using. --- diff --git a/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs b/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs index b748903..c1d6d63 100644 --- a/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs +++ b/src/NextIteration.SpectreConsole.Auth/CommandConfiguratorExtensions.cs @@ -12,7 +12,8 @@ public static class CommandConfiguratorExtensions { /// /// Registers the accounts branch of credential-management commands - /// (add, list, select, delete). + /// (add, list, select, delete, export, + /// import, info). /// public static IConfigurator AddAccountsBranch(this IConfigurator configurator) { @@ -47,6 +48,10 @@ public static IConfigurator AddAccountsBranch(this IConfigurator configurator) .WithDescription("Import credentials from an encrypted archive") .WithExample("accounts", "import", "credentials.bundle") .WithExample("accounts", "import", "credentials.bundle", "--on-conflict", "overwrite"); + + accounts.AddCommand("info") + .WithDescription("Show where and how credentials are stored") + .WithExample("accounts", "info"); }); return configurator; diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreDescriber.cs b/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreDescriber.cs new file mode 100644 index 0000000..3a483af --- /dev/null +++ b/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreDescriber.cs @@ -0,0 +1,157 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +using NextIteration.SpectreConsole.Auth.Encryption; +using NextIteration.SpectreConsole.Auth.Persistence; +using NextIteration.SpectreConsole.Auth.Persistence.Keychain; +using NextIteration.SpectreConsole.Auth.Persistence.Libsecret; + +namespace NextIteration.SpectreConsole.Auth.Commands +{ + /// + /// One labelled line of accounts info output. , when set, + /// explains why the value weakens the store (e.g. a credentials directory readable by + /// other users). + /// + internal sealed record CredentialStoreFact(string Label, string Value, string? Warning = null); + + /// + /// Builds the accounts info description from the + /// that DI actually resolved, rather than from . + /// + /// + /// Describing the resolved instance is deliberate. The DPAPI setup in the README registers + /// by hand without AddCredentialStore, so there + /// are no options to read; and a custom registered in + /// the wrong order is silently ignored. Inspecting what is wired up reports both truthfully. + /// Nothing here reads a secret or touches the OS secret stores, so the command cannot + /// trigger an unlock prompt. + /// + internal static class CredentialStoreDescriber + { + // Group and other permission bits. Any of them set on the credentials directory + // widens the file backend's only security boundary beyond the owning user. + private const UnixFileMode GroupOrOther = + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + internal static IReadOnlyList Describe(ICredentialManager credentialManager) + { + ArgumentNullException.ThrowIfNull(credentialManager); + + var facts = new List(); + + if (credentialManager is FileCredentialManager file) + { + DescribeFileBackend(file, facts); + } + else if (OperatingSystem.IsMacOS() && credentialManager is KeychainCredentialManager keychain) + { + DescribeKeychainBackend(keychain, facts); + } + else if (OperatingSystem.IsLinux() && credentialManager is LibsecretCredentialManager libsecret) + { + DescribeLibsecretBackend(libsecret, facts); + } + else + { + facts.Add(new("Backend", $"Custom ({credentialManager.GetType().FullName})")); + } + + facts.Add(new("Library version", LibraryVersion())); + facts.Add(new("Platform", $"{RuntimeInformation.OSDescription} ({RuntimeInformation.RuntimeIdentifier})")); + + return facts; + } + + private static void DescribeFileBackend(FileCredentialManager file, List facts) + { + var directory = file.CredentialsDirectoryPath; + + facts.Add(new("Backend", "Encrypted files")); + facts.Add(new("Credentials directory", Path.GetFullPath(directory))); + facts.Add(DescribeDirectoryPermissions(directory)); + + switch (file.Encryption) + { + case LocalFileCredentialEncryption local: + facts.Add(new("Encryption", "AES-256-GCM (data key sealed with PBKDF2-HMAC-SHA256)")); + facts.Add(new("Keystore", Path.GetFullPath(local.KeystorePath) + (File.Exists(local.KeystorePath) ? string.Empty : " (not created yet)"))); + facts.Add(local.HasAdditionalEntropy + ? new("Key binding", "AdditionalEntropy (hardened)") + : new("Key binding", "None (default; portable, protected by directory permissions)")); + break; + + case var encryption when OperatingSystem.IsWindows() && encryption is DpapiCredentialEncryption: + facts.Add(new("Encryption", "Windows DPAPI (current user scope)")); + facts.Add(new("Key binding", "Windows user profile")); + break; + + case var encryption: + facts.Add(new("Encryption", $"Custom ({encryption.GetType().FullName})")); + break; + } + + facts.Add(new("Selections file", Path.GetFullPath(file.SelectionFilePath))); + } + + [SupportedOSPlatform("macos")] + private static void DescribeKeychainBackend(KeychainCredentialManager keychain, List facts) + { + facts.Add(new("Backend", "macOS Keychain (experimental)")); + facts.Add(new("App identifier", keychain.AppIdentifier)); + facts.Add(new("Items", $"Generic passwords, service '{keychain.AppIdentifier}.'")); + facts.Add(new("Encryption", "Managed by macOS Keychain")); + } + + [SupportedOSPlatform("linux")] + private static void DescribeLibsecretBackend(LibsecretCredentialManager libsecret, List facts) + { + facts.Add(new("Backend", "Secret Service via libsecret (experimental)")); + facts.Add(new("App identifier", libsecret.AppIdentifier)); + facts.Add(new("Collection", libsecret.Collection)); + facts.Add(new("Encryption", "Managed by the Secret Service daemon")); + } + + internal static CredentialStoreFact DescribeDirectoryPermissions(string directory) + { + const string label = "Directory permissions"; + + try + { + if (OperatingSystem.IsWindows()) + { + var isProtected = new DirectoryInfo(directory).GetAccessControl().AreAccessRulesProtected; + return isProtected + ? new(label, "Explicit ACL (inheritance disabled)") + : new(label, "Inherited from parent folder", + Warning: "The credentials directory inherits its ACL, so other accounts may be able to read it."); + } + + var mode = File.GetUnixFileMode(directory); + var octal = Convert.ToString((int)mode & 0x1FF, 8).PadLeft(4, '0'); + return (mode & GroupOrOther) == 0 + ? new(label, $"{octal} (owner only)") + : new(label, octal, + Warning: "The credentials directory is accessible to group or other users. Running 'chmod 700' on it restricts it to its owner."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return new(label, "Unknown", Warning: $"Could not read the credentials directory's permissions: {ex.Message}"); + } + } + + private static string LibraryVersion() + { + var assembly = typeof(CredentialStoreDescriber).Assembly; + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + + // Drop the SourceLink "+" suffix; the version is what users quote. + var plus = version.IndexOf('+', StringComparison.Ordinal); + return plus < 0 ? version : version[..plus]; + } + } +} diff --git a/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreInfoCommand.cs b/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreInfoCommand.cs new file mode 100644 index 0000000..a4e6fb1 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Auth/Commands/CredentialStoreInfoCommand.cs @@ -0,0 +1,67 @@ +using NextIteration.SpectreConsole.Auth.Persistence; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace NextIteration.SpectreConsole.Auth.Commands +{ + /// + /// Spectre.Console command for accounts info. Shows where and how credentials + /// are stored: the backend, the on-disk paths for the file backend, the encryption in + /// use, and whether the store is bound to a secret. + /// + /// + /// + /// The output describes the that DI actually resolved, + /// so it also answers "is my custom ICredentialEncryption really in use?". + /// + /// + /// It never decrypts a credential or queries an OS secret store, so it is safe to run on + /// a locked keychain and cannot prompt. Secret values, including + /// , are reported only as present + /// or absent. + /// + /// DI constructor. + /// + public sealed class CredentialStoreInfoCommand(ICredentialManager credentialManager) : Command + { + private readonly ICredentialManager _credentialManager = credentialManager; + + /// + public override int Execute(CommandContext context, AccountsCommandSettings settings, CancellationToken cancellationToken) + { + try + { + var facts = CredentialStoreDescriber.Describe(_credentialManager); + var labelWidth = facts.Max(f => f.Label.Length); + + foreach (var fact in facts) + { + // The label goes through Spectre for styling, but the value is written + // raw: Spectre hard-wraps anything wider than the console, splitting a + // long path with real newlines so it no longer copy-pastes. Left to the + // terminal, an over-long line soft-wraps and copies intact. + AnsiConsole.Markup($"[bold]{Markup.Escape(fact.Label.PadRight(labelWidth))}[/] "); + AnsiConsole.Profile.Out.Writer.WriteLine(fact.Value); + } + + var warnings = facts.Where(f => f.Warning is not null).ToList(); + if (warnings.Count > 0) + { + AnsiConsole.WriteLine(); + foreach (var fact in warnings) + { + AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(fact.Warning!)}[/]"); + } + } + + return 0; + } + catch (Exception ex) + { + CommandErrorReporter.Report(ex, "Error describing the credential store", settings.Verbose); + return 1; + } + } + } +} diff --git a/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs b/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs index ee77da3..85054f8 100644 --- a/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs +++ b/src/NextIteration.SpectreConsole.Auth/Encryption/LocalFileCredentialEncryption.cs @@ -171,6 +171,15 @@ public LocalFileCredentialEncryption(string credentialsDirectory, byte[]? additi _dataKey = new Lazy>(LoadOrCreateDataKeyAsync, LazyThreadSafetyMode.ExecutionAndPublication); } + /// Path of the .keystore file. Read by accounts info. + internal string KeystorePath => _keyFile; + + /// + /// Whether caller-supplied entropy is mixed into the KEK (hardened mode). Reports + /// only its presence — the value itself is never exposed. + /// + internal bool HasAdditionalEntropy => _callerEntropy is not null; + /// public async Task EncryptAsync(string plainText) { diff --git a/src/NextIteration.SpectreConsole.Auth/NextIteration.SpectreConsole.Auth.csproj b/src/NextIteration.SpectreConsole.Auth/NextIteration.SpectreConsole.Auth.csproj index ea1d023..f6eb982 100644 --- a/src/NextIteration.SpectreConsole.Auth/NextIteration.SpectreConsole.Auth.csproj +++ b/src/NextIteration.SpectreConsole.Auth/NextIteration.SpectreConsole.Auth.csproj @@ -12,7 +12,7 @@ NextIteration.SpectreConsole.Auth - 2.2.0 + 2.3.0 Credential storage, encryption, and Spectre.Console CLI commands for managing provider credentials in CLI tools. true $(MSBuildThisFileDirectory)..\..\artifacts\packages diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs index 52e03cb..2d8a88e 100644 --- a/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs +++ b/src/NextIteration.SpectreConsole.Auth/Persistence/FileCredentialManager.cs @@ -49,6 +49,15 @@ public FileCredentialManager( EnsureDirectoryExists(); } + /// The directory this manager stores credentials in. Read by accounts info. + internal string CredentialsDirectoryPath => _credentialsDirectory; + + /// The selections file inside . + internal string SelectionFilePath => _selectionFile; + + /// The encryption backend actually in use, which may be a custom one. + internal ICredentialEncryption Encryption => _encryption; + /// public async Task> ListCredentialsAsync(string providerName, CancellationToken cancellationToken = default) { diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs index 17a856a..80e1abc 100644 --- a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs +++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs @@ -58,6 +58,9 @@ public KeychainCredentialManager( .ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase); } + /// The identifier scoping this CLI's Keychain items. Read by accounts info. + internal string AppIdentifier => _appIdentifier; + /// public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData, CancellationToken cancellationToken = default) { diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs index cd7d009..7c892c2 100644 --- a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs +++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs @@ -90,6 +90,12 @@ public LibsecretCredentialManager( .ToDictionary(p => p.ProviderName, StringComparer.OrdinalIgnoreCase); } + /// The identifier scoping this CLI's keyring items. Read by accounts info. + internal string AppIdentifier => _appIdentifier; + + /// The Secret Service collection new items are written to. + internal string Collection => _collection; + /// public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData, CancellationToken cancellationToken = default) { diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CredentialStoreDescriberTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CredentialStoreDescriberTests.cs new file mode 100644 index 0000000..94ed30d --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Commands/CredentialStoreDescriberTests.cs @@ -0,0 +1,149 @@ +using NextIteration.SpectreConsole.Auth.Commands; +using NextIteration.SpectreConsole.Auth.Encryption; +using NextIteration.SpectreConsole.Auth.Persistence; +using NextIteration.SpectreConsole.Auth.Tests.Infrastructure; + +using Xunit; + +namespace NextIteration.SpectreConsole.Auth.Tests.Commands +{ + public sealed class CredentialStoreDescriberTests + { + private static string ValueOf(IReadOnlyList facts, string label) => + Assert.Single(facts, f => f.Label == label).Value; + + [Fact] + public void Describe_FileBackendDefaultMode_ReportsPathsAndPortableKeyBinding() + { + using var temp = new TempDir(); + var manager = new FileCredentialManager(new LocalFileCredentialEncryption(temp.Path), temp.Path); + + var facts = CredentialStoreDescriber.Describe(manager); + + Assert.Equal("Encrypted files", ValueOf(facts, "Backend")); + Assert.Equal(Path.GetFullPath(temp.Path), ValueOf(facts, "Credentials directory")); + Assert.StartsWith("AES-256-GCM", ValueOf(facts, "Encryption"), StringComparison.Ordinal); + var keyBinding = Assert.Single(facts, f => f.Label == "Key binding"); + Assert.StartsWith("None (default; portable", keyBinding.Value, StringComparison.Ordinal); + Assert.Equal(Path.Join(Path.GetFullPath(temp.Path), "selections.json"), ValueOf(facts, "Selections file")); + } + + [Fact] + public async Task Describe_Keystore_ReportsWhetherItExistsYet() + { + using var temp = new TempDir(); + var encryption = new LocalFileCredentialEncryption(temp.Path); + var manager = new FileCredentialManager(encryption, temp.Path); + var keystore = Path.Join(Path.GetFullPath(temp.Path), ".keystore"); + + Assert.Equal(keystore + " (not created yet)", ValueOf(CredentialStoreDescriber.Describe(manager), "Keystore")); + + _ = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{}"); + + Assert.Equal(keystore, ValueOf(CredentialStoreDescriber.Describe(manager), "Keystore")); + } + + [Fact] + public void Describe_FileBackendWithEntropy_ReportsHardenedWithoutTheValue() + { + using var temp = new TempDir(); + var entropy = "per-deployment-secret"u8.ToArray(); + var manager = new FileCredentialManager(new LocalFileCredentialEncryption(temp.Path, entropy), temp.Path); + + var facts = CredentialStoreDescriber.Describe(manager); + + var keyBinding = Assert.Single(facts, f => f.Label == "Key binding"); + Assert.Equal("AdditionalEntropy (hardened)", keyBinding.Value); + Assert.Null(keyBinding.Warning); + Assert.DoesNotContain(facts, f => $"{f.Value} {f.Warning}".Contains("per-deployment-secret", StringComparison.Ordinal)); + } + + [Fact] + public void Describe_CustomEncryption_NamesTheTypeActuallyInUse() + { + using var temp = new TempDir(); + var manager = new FileCredentialManager(new FakeEncryption(), temp.Path); + + var facts = CredentialStoreDescriber.Describe(manager); + + Assert.Equal($"Custom ({typeof(FakeEncryption).FullName})", ValueOf(facts, "Encryption")); + Assert.DoesNotContain(facts, f => f.Label is "Keystore" or "Key binding"); + } + + [Fact] + public void Describe_CustomManager_NamesTheTypeAndStillReportsVersionAndPlatform() + { + var facts = CredentialStoreDescriber.Describe(new FakeManager()); + + Assert.Equal($"Custom ({typeof(FakeManager).FullName})", ValueOf(facts, "Backend")); + Assert.DoesNotContain("+", ValueOf(facts, "Library version"), StringComparison.Ordinal); + Assert.NotEmpty(ValueOf(facts, "Platform")); + } + + [Fact] + public void DescribeDirectoryPermissions_OwnerOnlyDirectory_IsNotAWarning() + { + using var temp = new TempDir(); + var directory = Path.Join(temp.Path, "creds"); + _ = new FileCredentialManager(new FakeEncryption(), directory); // creates it hardened + + var fact = CredentialStoreDescriber.DescribeDirectoryPermissions(directory); + + Assert.Null(fact.Warning); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal("0700 (owner only)", fact.Value); + } + } + + [Fact] + public void DescribeDirectoryPermissions_GroupReadableDirectory_IsAWarning() + { + if (OperatingSystem.IsWindows()) + { + return; // Unix mode bits; the Windows ACL path is covered by the owner-only test. + } + + using var temp = new TempDir(); + File.SetUnixFileMode(temp.Path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute); + + var fact = CredentialStoreDescriber.DescribeDirectoryPermissions(temp.Path); + + Assert.Equal("0750", fact.Value); + Assert.NotNull(fact.Warning); + } + + [Fact] + public void DescribeDirectoryPermissions_MissingDirectory_ReportsUnknownWithoutThrowing() + { + using var temp = new TempDir(); + + var fact = CredentialStoreDescriber.DescribeDirectoryPermissions(Path.Join(temp.Path, "absent")); + + Assert.Equal("Unknown", fact.Value); + Assert.NotNull(fact.Warning); + } + + private sealed class FakeEncryption : ICredentialEncryption + { + public Task EncryptAsync(string plainText) => Task.FromResult(plainText); + + public Task DecryptAsync(string encryptedText) => Task.FromResult(encryptedText); + } + + private sealed class FakeManager : ICredentialManager + { + public Task> ListCredentialsAsync(string providerName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task AddCredentialAsync(string providerName, string accountName, string environment, string credentialData, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DeleteCredentialAsync(string accountId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SelectCredentialAsync(string accountId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetSelectedCredentialAsync(string providerName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetCredentialByIdAsync(string providerName, string accountId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetProviderNamesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ExportCredentialsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task RestoreCredentialAsync(CredentialExport credential, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + } +}