diff --git a/CredentialCache.Test/LibsecretMissingTests.cs b/CredentialCache.Test/LibsecretMissingTests.cs
new file mode 100644
index 0000000..550d8d4
--- /dev/null
+++ b/CredentialCache.Test/LibsecretMissingTests.cs
@@ -0,0 +1,146 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CredentialCache.Test;
+
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using ktsu.CredentialCache.Storage;
+
+///
+/// Covers the Linux store on a host without libsecret (a headless server, SSH session or
+/// container). Callers are told to catch and fall back,
+/// so that is what every operation must throw, never a .
+///
+[TestClass]
+public class LibsecretMissingTests
+{
+ [TestMethod]
+ public void TranslateMissingLibraryWrapsDllNotFoundException()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ Assert.Inconclusive("The libsecret store is only built on Linux.");
+ return;
+ }
+
+ AssertTranslated(new DllNotFoundException("Unable to load shared library 'libsecret-1.so.0'"));
+ }
+
+ [TestMethod]
+ public void TranslateMissingLibraryWrapsEntryPointNotFoundException()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ Assert.Inconclusive("The libsecret store is only built on Linux.");
+ return;
+ }
+
+ AssertTranslated(new EntryPointNotFoundException("secret_schema_new"));
+ }
+
+ [TestMethod]
+ public void TranslateMissingLibraryLeavesOtherFailuresAlone()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ Assert.Inconclusive("The libsecret store is only built on Linux.");
+ return;
+ }
+
+ AssertNotTranslated();
+ }
+
+ [TestMethod]
+ public void SchemaCacheCreatesTheHandleOnceAndRetriesAfterAFailure()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ Assert.Inconclusive("The libsecret store is only built on Linux.");
+ return;
+ }
+
+ AssertSchemaCacheBehaviour();
+ }
+
+ [TestMethod]
+ public void EveryOperationThrowsCredentialStoreExceptionWhenLibsecretIsMissing()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ Assert.Inconclusive("The libsecret store is only built on Linux.");
+ return;
+ }
+
+ if (NativeLibrary.TryLoad("libsecret-1.so.0", out IntPtr handle))
+ {
+ NativeLibrary.Free(handle);
+ Assert.Inconclusive("libsecret is installed here, so the missing-library path cannot be reached.");
+ return;
+ }
+
+ AssertEveryOperationThrows();
+ }
+
+ [SupportedOSPlatform("linux")]
+ private static void AssertTranslated(Exception nativeFailure)
+ {
+ CredentialStoreException exception = Assert.ThrowsExactly(
+ () => LinuxSecretServiceCredentialStore.TranslateMissingLibrary(() => throw nativeFailure));
+
+ Assert.AreSame(nativeFailure, exception.InnerException);
+ StringAssert.Contains(exception.Message, "libsecret");
+ }
+
+ [SupportedOSPlatform("linux")]
+ private static void AssertNotTranslated()
+ {
+ Assert.AreEqual(42, LinuxSecretServiceCredentialStore.TranslateMissingLibrary(() => 42));
+ Assert.ThrowsExactly(
+ () => LinuxSecretServiceCredentialStore.TranslateMissingLibrary(() => throw new InvalidOperationException()));
+ }
+
+ [SupportedOSPlatform("linux")]
+ private static void AssertSchemaCacheBehaviour()
+ {
+ LinuxSecretServiceCredentialStore.NativeHandleCache cache = new();
+ int calls = 0;
+
+ // A failure is not cached: the next call tries again rather than rethrowing.
+ Assert.ThrowsExactly(() => cache.GetOrCreate(() =>
+ {
+ calls++;
+ throw new CredentialStoreException("libsecret-1.so.0 could not be loaded.");
+ }));
+
+ IntPtr created = cache.GetOrCreate(() =>
+ {
+ calls++;
+ return new IntPtr(42);
+ });
+ IntPtr cached = cache.GetOrCreate(() =>
+ {
+ calls++;
+ return new IntPtr(7);
+ });
+
+ Assert.AreEqual(new IntPtr(42), created);
+ Assert.AreEqual(created, cached);
+ Assert.AreEqual(2, calls);
+ }
+
+ [SupportedOSPlatform("linux")]
+ private static void AssertEveryOperationThrows()
+ {
+ LinuxSecretServiceCredentialStore store = new($"ktsu.CredentialCache.MissingLibsecretTest.{Guid.NewGuid():N}");
+ PersonaGUID persona = CredentialCache.CreatePersonaGUID();
+
+ // Twice each: a type initializer would fail once and then rethrow its cached
+ // TypeInitializationException on every later call.
+ for (int attempt = 0; attempt < 2; attempt++)
+ {
+ Assert.ThrowsExactly(() => store.TryLoad(persona, out _));
+ Assert.ThrowsExactly(() => store.Remove(persona));
+ Assert.ThrowsExactly(() => store.Save(persona, new CredentialWithNothing()));
+ }
+ }
+}
diff --git a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
index e4e7079..df8b557 100644
--- a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
+++ b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
@@ -15,8 +15,10 @@ namespace ktsu.CredentialCache.Storage;
///
/// Requires libsecret to be installed on the host. On headless systems without
/// a running secret-service implementation (e.g. minimal containers, build agents)
-/// this provider will fail at the first operation; consumers should detect this
-/// and fall back to if appropriate.
+/// this provider throws from every operation;
+/// consumers should catch it and fall back to if
+/// appropriate. A missing libsecret-1.so.0 surfaces the same way, with the
+/// as its inner exception.
///
///
/// Plaintext credential bytes are scrubbed on the same terms as the Windows and macOS
@@ -50,7 +52,7 @@ public bool TryLoad(PersonaGUID persona, out Credential? credential)
IntPtr error = IntPtr.Zero;
IntPtr passwordPtr = NativeMethods.secret_password_lookup_sync(
- Schema.Handle,
+ GetSchemaHandle(),
IntPtr.Zero,
ref error,
"service", _serviceName,
@@ -94,7 +96,7 @@ public void Save(PersonaGUID persona, Credential credential)
IntPtr error = IntPtr.Zero;
bool stored = NativeMethods.secret_password_store_sync(
- Schema.Handle,
+ GetSchemaHandle(),
IntPtr.Zero,
label,
value.Pointer,
@@ -119,7 +121,7 @@ public bool Remove(PersonaGUID persona)
IntPtr error = IntPtr.Zero;
bool removed = NativeMethods.secret_password_clear_sync(
- Schema.Handle,
+ GetSchemaHandle(),
IntPtr.Zero,
ref error,
"service", _serviceName,
@@ -153,14 +155,65 @@ internal static void ThrowIfError(IntPtr error, string operation)
throw new CredentialStoreException($"{operation} failed: {message ?? ""}");
}
- private static class Schema
- {
- internal static readonly IntPtr Handle = NativeMethods.secret_schema_new(
+ private static readonly NativeHandleCache Schema = new();
+
+ ///
+ /// Builds the schema on first use rather than in a type initializer: a load failure
+ /// there would reach callers as a on every
+ /// call, which nobody would think to catch.
+ ///
+ private static IntPtr GetSchemaHandle() =>
+ Schema.GetOrCreate(() => TranslateMissingLibrary(() => NativeMethods.secret_schema_new(
"dev.ktsu.CredentialCache",
flags: 0,
"service", 0,
"account", 0,
- IntPtr.Zero);
+ IntPtr.Zero)));
+
+ ///
+ /// Holds a native handle created on first use. A failed creation is not cached, so every
+ /// later call fails the same way as the first instead of rethrowing a stale exception.
+ ///
+ internal sealed class NativeHandleCache
+ {
+ private readonly Lock _lock = new();
+ private IntPtr _handle;
+
+ ///
+ /// Returns the cached handle, creating it with if there is none.
+ ///
+ /// Creates the handle.
+ /// The handle.
+ internal IntPtr GetOrCreate(Func create)
+ {
+ lock (_lock)
+ {
+ if (_handle == IntPtr.Zero)
+ {
+ _handle = create();
+ }
+
+ return _handle;
+ }
+ }
+ }
+
+ ///
+ /// Runs a native call, turning a missing or incompatible libsecret into the store's
+ /// documented .
+ ///
+ internal static T TranslateMissingLibrary(Func nativeCall)
+ {
+ try
+ {
+ return nativeCall();
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
+ {
+ throw new CredentialStoreException(
+ $"libsecret-1.so.0 could not be loaded. Install libsecret-1-0 and a Secret Service implementation, or use {nameof(InMemoryCredentialStore)}.",
+ ex);
+ }
}
private static class NativeMethods