Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions .github/workflows/cross-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,32 @@ 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

- 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
'
197 changes: 197 additions & 0 deletions CredentialCache.Test/NativeCredentialStoreTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Exercises the platform-native credential store returned by
/// <see cref="CredentialStoreFactory.CreateDefault(string)"/> 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.
/// <c>gnome-keyring-daemon</c> launched under <c>dbus-run-session</c>). The
/// cross-platform CI workflow provides one; locally they will fail-fast with
/// a clear <see cref="CredentialStoreException"/> if no daemon is available.
/// </summary>
[TestClass]
public class NativeCredentialStoreTests
{
private static string UniqueServiceName() =>
$"ktsu.CredentialCache.IntegrationTest.{Guid.NewGuid():N}";

private static ICredentialStore CreateNativeStore() =>
CredentialStoreFactory.CreateDefault(UniqueServiceName());

/// <summary>
/// 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
/// <see cref="Assert.Inconclusive(string)"/> rather than failed, so a
/// developer without a keyring set up doesn't see scary red.
/// </summary>
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<CredentialToken>.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<CredentialToken>.Create("first"),
});
store.Save(persona, new CredentialWithToken
{
Token = SemanticString<CredentialToken>.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<CredentialUsername>.Create("bob"),
Password = SemanticString<CredentialPassword>.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<PersonaGUID> keys = searchable.EnumerateKeys();
Assert.IsTrue(keys.Any(k => string.Equals(k.ToString(), persona.ToString(), StringComparison.Ordinal)));
}
finally
{
searchable!.Remove(persona);
}
}
}

/// <summary>
/// Small helper that wires TryLoad through as TryGet for symmetry with
/// CredentialCache's API in test assertions. Keeps the assertion sites readable.
/// </summary>
internal static class CredentialStoreTestExtensions
{
public static bool TryGet(this ICredentialStore store, PersonaGUID persona, out Credential? credential)
=> store.TryLoad(persona, out credential);
}
5 changes: 0 additions & 5 deletions CredentialCache/Storage/ICredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,4 @@ public interface ICredentialStore
/// Removes any credential associated with <paramref name="persona"/>.
/// </summary>
public bool Remove(PersonaGUID persona);

/// <summary>
/// Enumerates every persona key currently stored.
/// </summary>
public IEnumerable<PersonaGUID> EnumerateKeys();
}
21 changes: 21 additions & 0 deletions CredentialCache/Storage/ISearchableCredentialStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.CredentialCache.Storage;

/// <summary>
/// An <see cref="ICredentialStore"/> that can enumerate its persisted keys.
/// Only some backends support this efficiently - e.g. Windows Credential
/// Manager via <c>CredEnumerate</c>. 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 <see cref="PersonaGUID"/> values themselves.
/// </summary>
public interface ISearchableCredentialStore : ICredentialStore
{
/// <summary>
/// Enumerates every persona key currently persisted in this store.
/// </summary>
public IEnumerable<PersonaGUID> EnumerateKeys();
}
7 changes: 1 addition & 6 deletions CredentialCache/Storage/InMemoryCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,18 @@ 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.
/// </summary>
public sealed class InMemoryCredentialStore : ICredentialStore
public sealed class InMemoryCredentialStore : ISearchableCredentialStore
{

private readonly ConcurrentDictionary<PersonaGUID, Credential> _items = new();

/// <inheritdoc/>

public string Name => "InMemory";

/// <inheritdoc/>
public bool TryLoad(PersonaGUID persona, out Credential? credential)
{
ArgumentNullException.ThrowIfNull(persona);
return _items.TryGetValue(persona, out credential);

}

/// <inheritdoc/>
Expand All @@ -33,15 +30,13 @@ public void Save(PersonaGUID persona, Credential credential)
ArgumentNullException.ThrowIfNull(persona);
ArgumentNullException.ThrowIfNull(credential);
_items[persona] = credential;

}

/// <inheritdoc/>
public bool Remove(PersonaGUID persona)
{
ArgumentNullException.ThrowIfNull(persona);
return _items.TryRemove(persona, out _);

}

/// <inheritdoc/>
Expand Down
9 changes: 0 additions & 9 deletions CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,6 @@ public bool Remove(PersonaGUID persona)
return removed;
}

/// <inheritdoc/>
public IEnumerable<PersonaGUID> 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 <see cref="CredentialCache"/>.
yield break;
}

private static void ThrowIfError(IntPtr error, string operation)
{
if (error == IntPtr.Zero)
Expand Down
10 changes: 0 additions & 10 deletions CredentialCache/Storage/MacOsCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,6 @@ public bool Remove(PersonaGUID persona)
}
}

/// <inheritdoc/>
public IEnumerable<PersonaGUID> 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 <see cref="CredentialCache"/>.
yield break;
}

private static class NativeMethods
{
internal const int errSecSuccess = 0;
Expand Down
2 changes: 1 addition & 1 deletion CredentialCache/Storage/WindowsCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace ktsu.CredentialCache.Storage;
/// representation of the <see cref="Credential"/>.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class WindowsCredentialStore : ICredentialStore
internal sealed class WindowsCredentialStore : ISearchableCredentialStore
{
internal const string DefaultServicePrefix = "ktsu.CredentialCache";

Expand Down
2 changes: 1 addition & 1 deletion VERSION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.0
2.0.0
Loading