Skip to content

Commit c63e937

Browse files
Merge pull request #61 from ktsu-dev/claude/finish-cross-platform-cleanup
Add native-store tests, split EnumerateKeys, bump to 2.0.0
2 parents 6c7567b + fb54d65 commit c63e937

9 files changed

Lines changed: 239 additions & 35 deletions

.github/workflows/cross-platform.yml

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,32 @@ jobs:
4040
with:
4141
dotnet-version: 10.0.x
4242

43-
- name: Install libsecret (Linux)
43+
- name: Install libsecret + dbus + gnome-keyring (Linux)
4444
if: runner.os == 'Linux'
4545
run: |
4646
sudo apt-get update
47-
sudo apt-get install -y libsecret-1-0
47+
sudo apt-get install -y libsecret-1-0 dbus dbus-x11 gnome-keyring
4848
4949
- name: Restore
5050
run: dotnet restore CredentialCache.sln
5151

5252
- name: Build
5353
run: dotnet build CredentialCache.sln --configuration Release --no-restore
5454

55-
- name: Test
55+
- name: Test (non-Linux)
56+
if: runner.os != 'Linux'
5657
run: dotnet test --project CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build
58+
59+
- name: Test (Linux, under dbus session with gnome-keyring)
60+
if: runner.os == 'Linux'
61+
shell: bash
62+
# The LinuxSecretServiceCredentialStore tests need a running Secret
63+
# Service. We spin up a private dbus session, start gnome-keyring-daemon
64+
# inside it, unlock with an empty password, then run the test runner
65+
# within the same session so libsecret can reach the daemon.
66+
run: |
67+
dbus-run-session -- bash -c '
68+
printf "\n" | gnome-keyring-daemon --unlock --components=secrets >/dev/null 2>&1 &
69+
sleep 1
70+
dotnet test --project CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build
71+
'
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.CredentialCache.Test;
6+
7+
using System.Runtime.InteropServices;
8+
using ktsu.CredentialCache.Storage;
9+
using ktsu.Semantics.Strings;
10+
11+
/// <summary>
12+
/// Exercises the platform-native credential store returned by
13+
/// <see cref="CredentialStoreFactory.CreateDefault(string)"/> on whatever OS
14+
/// the test happens to be running on. The store is scoped to a per-run service
15+
/// name so the tests don't collide with other applications' real credentials.
16+
///
17+
/// On Linux these require a running Secret Service implementation (e.g.
18+
/// <c>gnome-keyring-daemon</c> launched under <c>dbus-run-session</c>). The
19+
/// cross-platform CI workflow provides one; locally they will fail-fast with
20+
/// a clear <see cref="CredentialStoreException"/> if no daemon is available.
21+
/// </summary>
22+
[TestClass]
23+
public class NativeCredentialStoreTests
24+
{
25+
private static string UniqueServiceName() =>
26+
$"ktsu.CredentialCache.IntegrationTest.{Guid.NewGuid():N}";
27+
28+
private static ICredentialStore CreateNativeStore() =>
29+
CredentialStoreFactory.CreateDefault(UniqueServiceName());
30+
31+
/// <summary>
32+
/// Performs a tiny no-op call against the native store to verify the platform
33+
/// dependencies are actually present (e.g. libsecret loaded and a Secret
34+
/// Service daemon is reachable on Linux). If not, the test is reported as
35+
/// <see cref="Assert.Inconclusive(string)"/> rather than failed, so a
36+
/// developer without a keyring set up doesn't see scary red.
37+
/// </summary>
38+
private static void AssertNativeStoreAvailableOrInconclusive(ICredentialStore store)
39+
{
40+
PersonaGUID probe = CredentialCache.CreatePersonaGUID();
41+
try
42+
{
43+
// Removing a key that doesn't exist must not throw on a working backend.
44+
_ = store.Remove(probe);
45+
}
46+
catch (Exception ex) when (IsMissingPlatformDependency(ex))
47+
{
48+
Assert.Inconclusive($"Native credential store is not available in this environment: {ex.GetType().Name}: {ex.Message}");
49+
}
50+
}
51+
52+
private static bool IsMissingPlatformDependency(Exception ex)
53+
{
54+
// Walk the inner-exception chain - libsecret/Security.framework load
55+
// failures surface inside a TypeInitializationException when triggered
56+
// during a static cctor (e.g. the libsecret schema handle).
57+
for (Exception? current = ex; current is not null; current = current.InnerException)
58+
{
59+
if (current is DllNotFoundException or CredentialStoreException)
60+
{
61+
return true;
62+
}
63+
}
64+
return false;
65+
}
66+
67+
[TestMethod]
68+
public void NativeStoreRoundTripsCredentialWithToken()
69+
{
70+
ICredentialStore store = CreateNativeStore();
71+
AssertNativeStoreAvailableOrInconclusive(store);
72+
PersonaGUID persona = CredentialCache.CreatePersonaGUID();
73+
Credential original = new CredentialWithToken
74+
{
75+
Token = SemanticString<CredentialToken>.Create("native-test-token"),
76+
};
77+
78+
try
79+
{
80+
store.Save(persona, original);
81+
82+
Assert.IsTrue(store.TryLoad(persona, out Credential? loaded));
83+
CredentialWithToken? typed = loaded as CredentialWithToken;
84+
Assert.IsNotNull(typed);
85+
Assert.AreEqual("native-test-token", typed!.Token.ToString());
86+
}
87+
finally
88+
{
89+
store.Remove(persona);
90+
}
91+
}
92+
93+
[TestMethod]
94+
public void NativeStoreSaveOverwritesExistingEntry()
95+
{
96+
ICredentialStore store = CreateNativeStore();
97+
AssertNativeStoreAvailableOrInconclusive(store);
98+
PersonaGUID persona = CredentialCache.CreatePersonaGUID();
99+
100+
try
101+
{
102+
store.Save(persona, new CredentialWithToken
103+
{
104+
Token = SemanticString<CredentialToken>.Create("first"),
105+
});
106+
store.Save(persona, new CredentialWithToken
107+
{
108+
Token = SemanticString<CredentialToken>.Create("second"),
109+
});
110+
111+
Assert.IsTrue(store.TryLoad(persona, out Credential? loaded));
112+
Assert.AreEqual("second", ((CredentialWithToken)loaded!).Token.ToString());
113+
}
114+
finally
115+
{
116+
store.Remove(persona);
117+
}
118+
}
119+
120+
[TestMethod]
121+
public void NativeStoreRemoveReturnsFalseForUnknownPersona()
122+
{
123+
ICredentialStore store = CreateNativeStore();
124+
AssertNativeStoreAvailableOrInconclusive(store);
125+
PersonaGUID persona = CredentialCache.CreatePersonaGUID();
126+
127+
Assert.IsFalse(store.Remove(persona));
128+
Assert.IsFalse(store.TryLoad(persona, out _));
129+
}
130+
131+
[TestMethod]
132+
public void NativeStoreSurvivesAcrossStoreInstances()
133+
{
134+
string service = UniqueServiceName();
135+
PersonaGUID persona = CredentialCache.CreatePersonaGUID();
136+
Credential original = new CredentialWithUsernamePassword
137+
{
138+
Username = SemanticString<CredentialUsername>.Create("bob"),
139+
Password = SemanticString<CredentialPassword>.Create("sekrit"),
140+
};
141+
142+
ICredentialStore writer = CredentialStoreFactory.CreateDefault(service);
143+
AssertNativeStoreAvailableOrInconclusive(writer);
144+
try
145+
{
146+
writer.Save(persona, original);
147+
148+
ICredentialStore reader = CredentialStoreFactory.CreateDefault(service);
149+
Assert.IsTrue(reader.TryGet(persona, out Credential? loaded));
150+
CredentialWithUsernamePassword? typed = loaded as CredentialWithUsernamePassword;
151+
Assert.IsNotNull(typed);
152+
Assert.AreEqual("bob", typed!.Username.ToString());
153+
Assert.AreEqual("sekrit", typed.Password.ToString());
154+
}
155+
finally
156+
{
157+
writer.Remove(persona);
158+
}
159+
}
160+
161+
[TestMethod]
162+
public void WindowsStoreEnumerateKeysReturnsWrittenPersonas()
163+
{
164+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
165+
{
166+
Assert.Inconclusive("EnumerateKeys is only implemented on Windows Credential Manager.");
167+
return;
168+
}
169+
170+
ICredentialStore store = CreateNativeStore();
171+
AssertNativeStoreAvailableOrInconclusive(store);
172+
ISearchableCredentialStore? searchable = store as ISearchableCredentialStore;
173+
Assert.IsNotNull(searchable, "Windows store should implement ISearchableCredentialStore.");
174+
175+
PersonaGUID persona = CredentialCache.CreatePersonaGUID();
176+
try
177+
{
178+
searchable!.Save(persona, new CredentialWithNothing());
179+
IEnumerable<PersonaGUID> keys = searchable.EnumerateKeys();
180+
Assert.IsTrue(keys.Any(k => string.Equals(k.ToString(), persona.ToString(), StringComparison.Ordinal)));
181+
}
182+
finally
183+
{
184+
searchable!.Remove(persona);
185+
}
186+
}
187+
}
188+
189+
/// <summary>
190+
/// Small helper that wires TryLoad through as TryGet for symmetry with
191+
/// CredentialCache's API in test assertions. Keeps the assertion sites readable.
192+
/// </summary>
193+
internal static class CredentialStoreTestExtensions
194+
{
195+
public static bool TryGet(this ICredentialStore store, PersonaGUID persona, out Credential? credential)
196+
=> store.TryLoad(persona, out credential);
197+
}

CredentialCache/Storage/ICredentialStore.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,4 @@ public interface ICredentialStore
3131
/// Removes any credential associated with <paramref name="persona"/>.
3232
/// </summary>
3333
public bool Remove(PersonaGUID persona);
34-
35-
/// <summary>
36-
/// Enumerates every persona key currently stored.
37-
/// </summary>
38-
public IEnumerable<PersonaGUID> EnumerateKeys();
3934
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.CredentialCache.Storage;
6+
7+
/// <summary>
8+
/// An <see cref="ICredentialStore"/> that can enumerate its persisted keys.
9+
/// Only some backends support this efficiently - e.g. Windows Credential
10+
/// Manager via <c>CredEnumerate</c>. The macOS and Linux native APIs require
11+
/// substantial extra marshalling to enumerate, so they intentionally do not
12+
/// implement this interface; callers needing enumeration on those platforms
13+
/// should track <see cref="PersonaGUID"/> values themselves.
14+
/// </summary>
15+
public interface ISearchableCredentialStore : ICredentialStore
16+
{
17+
/// <summary>
18+
/// Enumerates every persona key currently persisted in this store.
19+
/// </summary>
20+
public IEnumerable<PersonaGUID> EnumerateKeys();
21+
}

CredentialCache/Storage/InMemoryCredentialStore.cs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,18 @@ namespace ktsu.CredentialCache.Storage;
1010
/// A non-persistent credential store backed by an in-memory dictionary. Intended for
1111
/// tests and applications that explicitly opt out of platform-level persistence.
1212
/// </summary>
13-
public sealed class InMemoryCredentialStore : ICredentialStore
13+
public sealed class InMemoryCredentialStore : ISearchableCredentialStore
1414
{
15-
1615
private readonly ConcurrentDictionary<PersonaGUID, Credential> _items = new();
1716

1817
/// <inheritdoc/>
19-
2018
public string Name => "InMemory";
2119

2220
/// <inheritdoc/>
2321
public bool TryLoad(PersonaGUID persona, out Credential? credential)
2422
{
2523
ArgumentNullException.ThrowIfNull(persona);
2624
return _items.TryGetValue(persona, out credential);
27-
2825
}
2926

3027
/// <inheritdoc/>
@@ -33,15 +30,13 @@ public void Save(PersonaGUID persona, Credential credential)
3330
ArgumentNullException.ThrowIfNull(persona);
3431
ArgumentNullException.ThrowIfNull(credential);
3532
_items[persona] = credential;
36-
3733
}
3834

3935
/// <inheritdoc/>
4036
public bool Remove(PersonaGUID persona)
4137
{
4238
ArgumentNullException.ThrowIfNull(persona);
4339
return _items.TryRemove(persona, out _);
44-
4540
}
4641

4742
/// <inheritdoc/>

CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,6 @@ public bool Remove(PersonaGUID persona)
120120
return removed;
121121
}
122122

123-
/// <inheritdoc/>
124-
public IEnumerable<PersonaGUID> EnumerateKeys()
125-
{
126-
// libsecret search_sync requires GLib list/hash-table marshalling. Callers
127-
// needing enumeration can track keys themselves or rely on the in-memory
128-
// snapshot maintained by <see cref="CredentialCache"/>.
129-
yield break;
130-
}
131-
132123
private static void ThrowIfError(IntPtr error, string operation)
133124
{
134125
if (error == IntPtr.Zero)

CredentialCache/Storage/MacOsCredentialStore.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,6 @@ public bool Remove(PersonaGUID persona)
183183
}
184184
}
185185

186-
/// <inheritdoc/>
187-
public IEnumerable<PersonaGUID> EnumerateKeys()
188-
{
189-
// SecItemCopyMatching with a CFDictionary is required for enumeration. Implementing
190-
// the CoreFoundation marshalling for an enumerate-only path adds substantial native
191-
// surface; callers that need enumeration can track keys themselves or rely on the
192-
// in-memory snapshot maintained by <see cref="CredentialCache"/>.
193-
yield break;
194-
}
195-
196186
private static class NativeMethods
197187
{
198188
internal const int errSecSuccess = 0;

CredentialCache/Storage/WindowsCredentialStore.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ namespace ktsu.CredentialCache.Storage;
1616
/// representation of the <see cref="Credential"/>.
1717
/// </summary>
1818
[SupportedOSPlatform("windows")]
19-
internal sealed class WindowsCredentialStore : ICredentialStore
19+
internal sealed class WindowsCredentialStore : ISearchableCredentialStore
2020
{
2121
internal const string DefaultServicePrefix = "ktsu.CredentialCache";
2222

VERSION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.3.0
1+
2.0.0

0 commit comments

Comments
 (0)