diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml
index aa806e9..5c26bf4 100644
--- a/.github/workflows/cross-platform.yml
+++ b/.github/workflows/cross-platform.yml
@@ -40,11 +40,11 @@ jobs:
with:
dotnet-version: 10.0.x
- - name: Install libsecret (Linux)
+ - name: Install libsecret + dbus + gnome-keyring (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
- sudo apt-get install -y libsecret-1-0
+ sudo apt-get install -y libsecret-1-0 dbus dbus-x11 gnome-keyring
- name: Restore
run: dotnet restore CredentialCache.sln
@@ -52,5 +52,20 @@ jobs:
- name: Build
run: dotnet build CredentialCache.sln --configuration Release --no-restore
- - name: Test
+ - name: Test (non-Linux)
+ if: runner.os != 'Linux'
run: dotnet test --project CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build
+
+ - name: Test (Linux, under dbus session with gnome-keyring)
+ if: runner.os == 'Linux'
+ shell: bash
+ # The LinuxSecretServiceCredentialStore tests need a running Secret
+ # Service. We spin up a private dbus session, start gnome-keyring-daemon
+ # inside it, unlock with an empty password, then run the test runner
+ # within the same session so libsecret can reach the daemon.
+ run: |
+ dbus-run-session -- bash -c '
+ printf "\n" | gnome-keyring-daemon --unlock --components=secrets >/dev/null 2>&1 &
+ sleep 1
+ dotnet test --project CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build
+ '
diff --git a/CredentialCache.Test/NativeCredentialStoreTests.cs b/CredentialCache.Test/NativeCredentialStoreTests.cs
new file mode 100644
index 0000000..8b3a923
--- /dev/null
+++ b/CredentialCache.Test/NativeCredentialStoreTests.cs
@@ -0,0 +1,197 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.CredentialCache.Test;
+
+using System.Runtime.InteropServices;
+using ktsu.CredentialCache.Storage;
+using ktsu.Semantics.Strings;
+
+///
+/// Exercises the platform-native credential store returned by
+/// on whatever OS
+/// the test happens to be running on. The store is scoped to a per-run service
+/// name so the tests don't collide with other applications' real credentials.
+///
+/// On Linux these require a running Secret Service implementation (e.g.
+/// gnome-keyring-daemon launched under dbus-run-session). The
+/// cross-platform CI workflow provides one; locally they will fail-fast with
+/// a clear if no daemon is available.
+///
+[TestClass]
+public class NativeCredentialStoreTests
+{
+ private static string UniqueServiceName() =>
+ $"ktsu.CredentialCache.IntegrationTest.{Guid.NewGuid():N}";
+
+ private static ICredentialStore CreateNativeStore() =>
+ CredentialStoreFactory.CreateDefault(UniqueServiceName());
+
+ ///
+ /// Performs a tiny no-op call against the native store to verify the platform
+ /// dependencies are actually present (e.g. libsecret loaded and a Secret
+ /// Service daemon is reachable on Linux). If not, the test is reported as
+ /// rather than failed, so a
+ /// developer without a keyring set up doesn't see scary red.
+ ///
+ private static void AssertNativeStoreAvailableOrInconclusive(ICredentialStore store)
+ {
+ PersonaGUID probe = CredentialCache.CreatePersonaGUID();
+ try
+ {
+ // Removing a key that doesn't exist must not throw on a working backend.
+ _ = store.Remove(probe);
+ }
+ catch (Exception ex) when (IsMissingPlatformDependency(ex))
+ {
+ Assert.Inconclusive($"Native credential store is not available in this environment: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private static bool IsMissingPlatformDependency(Exception ex)
+ {
+ // Walk the inner-exception chain - libsecret/Security.framework load
+ // failures surface inside a TypeInitializationException when triggered
+ // during a static cctor (e.g. the libsecret schema handle).
+ for (Exception? current = ex; current is not null; current = current.InnerException)
+ {
+ if (current is DllNotFoundException or CredentialStoreException)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ [TestMethod]
+ public void NativeStoreRoundTripsCredentialWithToken()
+ {
+ ICredentialStore store = CreateNativeStore();
+ AssertNativeStoreAvailableOrInconclusive(store);
+ PersonaGUID persona = CredentialCache.CreatePersonaGUID();
+ Credential original = new CredentialWithToken
+ {
+ Token = SemanticString.Create("native-test-token"),
+ };
+
+ try
+ {
+ store.Save(persona, original);
+
+ Assert.IsTrue(store.TryLoad(persona, out Credential? loaded));
+ CredentialWithToken? typed = loaded as CredentialWithToken;
+ Assert.IsNotNull(typed);
+ Assert.AreEqual("native-test-token", typed!.Token.ToString());
+ }
+ finally
+ {
+ store.Remove(persona);
+ }
+ }
+
+ [TestMethod]
+ public void NativeStoreSaveOverwritesExistingEntry()
+ {
+ ICredentialStore store = CreateNativeStore();
+ AssertNativeStoreAvailableOrInconclusive(store);
+ PersonaGUID persona = CredentialCache.CreatePersonaGUID();
+
+ try
+ {
+ store.Save(persona, new CredentialWithToken
+ {
+ Token = SemanticString.Create("first"),
+ });
+ store.Save(persona, new CredentialWithToken
+ {
+ Token = SemanticString.Create("second"),
+ });
+
+ Assert.IsTrue(store.TryLoad(persona, out Credential? loaded));
+ Assert.AreEqual("second", ((CredentialWithToken)loaded!).Token.ToString());
+ }
+ finally
+ {
+ store.Remove(persona);
+ }
+ }
+
+ [TestMethod]
+ public void NativeStoreRemoveReturnsFalseForUnknownPersona()
+ {
+ ICredentialStore store = CreateNativeStore();
+ AssertNativeStoreAvailableOrInconclusive(store);
+ PersonaGUID persona = CredentialCache.CreatePersonaGUID();
+
+ Assert.IsFalse(store.Remove(persona));
+ Assert.IsFalse(store.TryLoad(persona, out _));
+ }
+
+ [TestMethod]
+ public void NativeStoreSurvivesAcrossStoreInstances()
+ {
+ string service = UniqueServiceName();
+ PersonaGUID persona = CredentialCache.CreatePersonaGUID();
+ Credential original = new CredentialWithUsernamePassword
+ {
+ Username = SemanticString.Create("bob"),
+ Password = SemanticString.Create("sekrit"),
+ };
+
+ ICredentialStore writer = CredentialStoreFactory.CreateDefault(service);
+ AssertNativeStoreAvailableOrInconclusive(writer);
+ try
+ {
+ writer.Save(persona, original);
+
+ ICredentialStore reader = CredentialStoreFactory.CreateDefault(service);
+ Assert.IsTrue(reader.TryGet(persona, out Credential? loaded));
+ CredentialWithUsernamePassword? typed = loaded as CredentialWithUsernamePassword;
+ Assert.IsNotNull(typed);
+ Assert.AreEqual("bob", typed!.Username.ToString());
+ Assert.AreEqual("sekrit", typed.Password.ToString());
+ }
+ finally
+ {
+ writer.Remove(persona);
+ }
+ }
+
+ [TestMethod]
+ public void WindowsStoreEnumerateKeysReturnsWrittenPersonas()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Inconclusive("EnumerateKeys is only implemented on Windows Credential Manager.");
+ return;
+ }
+
+ ICredentialStore store = CreateNativeStore();
+ AssertNativeStoreAvailableOrInconclusive(store);
+ ISearchableCredentialStore? searchable = store as ISearchableCredentialStore;
+ Assert.IsNotNull(searchable, "Windows store should implement ISearchableCredentialStore.");
+
+ PersonaGUID persona = CredentialCache.CreatePersonaGUID();
+ try
+ {
+ searchable!.Save(persona, new CredentialWithNothing());
+ IEnumerable keys = searchable.EnumerateKeys();
+ Assert.IsTrue(keys.Any(k => string.Equals(k.ToString(), persona.ToString(), StringComparison.Ordinal)));
+ }
+ finally
+ {
+ searchable!.Remove(persona);
+ }
+ }
+}
+
+///
+/// Small helper that wires TryLoad through as TryGet for symmetry with
+/// CredentialCache's API in test assertions. Keeps the assertion sites readable.
+///
+internal static class CredentialStoreTestExtensions
+{
+ public static bool TryGet(this ICredentialStore store, PersonaGUID persona, out Credential? credential)
+ => store.TryLoad(persona, out credential);
+}
diff --git a/CredentialCache/Storage/ICredentialStore.cs b/CredentialCache/Storage/ICredentialStore.cs
index 14726e3..713d53b 100644
--- a/CredentialCache/Storage/ICredentialStore.cs
+++ b/CredentialCache/Storage/ICredentialStore.cs
@@ -31,9 +31,4 @@ public interface ICredentialStore
/// Removes any credential associated with .
///
public bool Remove(PersonaGUID persona);
-
- ///
- /// Enumerates every persona key currently stored.
- ///
- public IEnumerable EnumerateKeys();
}
diff --git a/CredentialCache/Storage/ISearchableCredentialStore.cs b/CredentialCache/Storage/ISearchableCredentialStore.cs
new file mode 100644
index 0000000..166fe60
--- /dev/null
+++ b/CredentialCache/Storage/ISearchableCredentialStore.cs
@@ -0,0 +1,21 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.CredentialCache.Storage;
+
+///
+/// An that can enumerate its persisted keys.
+/// Only some backends support this efficiently - e.g. Windows Credential
+/// Manager via CredEnumerate. The macOS and Linux native APIs require
+/// substantial extra marshalling to enumerate, so they intentionally do not
+/// implement this interface; callers needing enumeration on those platforms
+/// should track values themselves.
+///
+public interface ISearchableCredentialStore : ICredentialStore
+{
+ ///
+ /// Enumerates every persona key currently persisted in this store.
+ ///
+ public IEnumerable EnumerateKeys();
+}
diff --git a/CredentialCache/Storage/InMemoryCredentialStore.cs b/CredentialCache/Storage/InMemoryCredentialStore.cs
index 47bb57a..6410ec0 100644
--- a/CredentialCache/Storage/InMemoryCredentialStore.cs
+++ b/CredentialCache/Storage/InMemoryCredentialStore.cs
@@ -10,13 +10,11 @@ namespace ktsu.CredentialCache.Storage;
/// A non-persistent credential store backed by an in-memory dictionary. Intended for
/// tests and applications that explicitly opt out of platform-level persistence.
///
-public sealed class InMemoryCredentialStore : ICredentialStore
+public sealed class InMemoryCredentialStore : ISearchableCredentialStore
{
-
private readonly ConcurrentDictionary _items = new();
///
-
public string Name => "InMemory";
///
@@ -24,7 +22,6 @@ public bool TryLoad(PersonaGUID persona, out Credential? credential)
{
ArgumentNullException.ThrowIfNull(persona);
return _items.TryGetValue(persona, out credential);
-
}
///
@@ -33,7 +30,6 @@ public void Save(PersonaGUID persona, Credential credential)
ArgumentNullException.ThrowIfNull(persona);
ArgumentNullException.ThrowIfNull(credential);
_items[persona] = credential;
-
}
///
@@ -41,7 +37,6 @@ public bool Remove(PersonaGUID persona)
{
ArgumentNullException.ThrowIfNull(persona);
return _items.TryRemove(persona, out _);
-
}
///
diff --git a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
index 291e43c..bcd9f9a 100644
--- a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
+++ b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
@@ -120,15 +120,6 @@ public bool Remove(PersonaGUID persona)
return removed;
}
- ///
- public IEnumerable EnumerateKeys()
- {
- // libsecret search_sync requires GLib list/hash-table marshalling. Callers
- // needing enumeration can track keys themselves or rely on the in-memory
- // snapshot maintained by .
- yield break;
- }
-
private static void ThrowIfError(IntPtr error, string operation)
{
if (error == IntPtr.Zero)
diff --git a/CredentialCache/Storage/MacOsCredentialStore.cs b/CredentialCache/Storage/MacOsCredentialStore.cs
index c40050a..1ee5404 100644
--- a/CredentialCache/Storage/MacOsCredentialStore.cs
+++ b/CredentialCache/Storage/MacOsCredentialStore.cs
@@ -183,16 +183,6 @@ public bool Remove(PersonaGUID persona)
}
}
- ///
- public IEnumerable EnumerateKeys()
- {
- // SecItemCopyMatching with a CFDictionary is required for enumeration. Implementing
- // the CoreFoundation marshalling for an enumerate-only path adds substantial native
- // surface; callers that need enumeration can track keys themselves or rely on the
- // in-memory snapshot maintained by .
- yield break;
- }
-
private static class NativeMethods
{
internal const int errSecSuccess = 0;
diff --git a/CredentialCache/Storage/WindowsCredentialStore.cs b/CredentialCache/Storage/WindowsCredentialStore.cs
index 87e6946..b96ebc7 100644
--- a/CredentialCache/Storage/WindowsCredentialStore.cs
+++ b/CredentialCache/Storage/WindowsCredentialStore.cs
@@ -16,7 +16,7 @@ namespace ktsu.CredentialCache.Storage;
/// representation of the .
///
[SupportedOSPlatform("windows")]
-internal sealed class WindowsCredentialStore : ICredentialStore
+internal sealed class WindowsCredentialStore : ISearchableCredentialStore
{
internal const string DefaultServicePrefix = "ktsu.CredentialCache";
diff --git a/VERSION.md b/VERSION.md
index f0bb29e..227cea2 100644
--- a/VERSION.md
+++ b/VERSION.md
@@ -1 +1 @@
-1.3.0
+2.0.0