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

/// <summary>
/// Covers the Linux store on a host without libsecret (a headless server, SSH session or
/// container). Callers are told to catch <see cref="CredentialStoreException"/> and fall back,
/// so that is what every operation must throw, never a <see cref="TypeInitializationException"/>.
/// </summary>
[TestClass]
public class LibsecretMissingTests
{
[TestMethod]
public void TranslateMissingLibraryWrapsDllNotFoundException()
{
if (!OperatingSystem.IsLinux())
{
Assert.Inconclusive("The libsecret store is only built on Linux.");
return;
}

Check warning on line 24 in CredentialCache.Test/LibsecretMissingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_CredentialCache&issues=AaESDyuPPtOxru3e3BoY&open=AaESDyuPPtOxru3e3BoY&pullRequest=184

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;
}

Check warning on line 36 in CredentialCache.Test/LibsecretMissingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_CredentialCache&issues=AaESDyuPPtOxru3e3BoZ&open=AaESDyuPPtOxru3e3BoZ&pullRequest=184

AssertTranslated(new EntryPointNotFoundException("secret_schema_new"));
}

[TestMethod]
public void TranslateMissingLibraryLeavesOtherFailuresAlone()
{
if (!OperatingSystem.IsLinux())
{
Assert.Inconclusive("The libsecret store is only built on Linux.");
return;
}

Check warning on line 48 in CredentialCache.Test/LibsecretMissingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_CredentialCache&issues=AaESDyuPPtOxru3e3Boa&open=AaESDyuPPtOxru3e3Boa&pullRequest=184

AssertNotTranslated();
}

[TestMethod]
public void SchemaCacheCreatesTheHandleOnceAndRetriesAfterAFailure()
{
if (!OperatingSystem.IsLinux())
{
Assert.Inconclusive("The libsecret store is only built on Linux.");
return;
}

Check warning on line 60 in CredentialCache.Test/LibsecretMissingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_CredentialCache&issues=AaESDyuPPtOxru3e3Bob&open=AaESDyuPPtOxru3e3Bob&pullRequest=184

AssertSchemaCacheBehaviour();
}

[TestMethod]
public void EveryOperationThrowsCredentialStoreExceptionWhenLibsecretIsMissing()
{
if (!OperatingSystem.IsLinux())
{
Assert.Inconclusive("The libsecret store is only built on Linux.");
return;
}

Check warning on line 72 in CredentialCache.Test/LibsecretMissingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_CredentialCache&issues=AaESFlZjLNgfEFUTp7R6&open=AaESFlZjLNgfEFUTp7R6&pullRequest=184

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<CredentialStoreException>(
() => LinuxSecretServiceCredentialStore.TranslateMissingLibrary<IntPtr>(() => throw nativeFailure));

Assert.AreSame(nativeFailure, exception.InnerException);
StringAssert.Contains(exception.Message, "libsecret");

Check warning on line 91 in CredentialCache.Test/LibsecretMissingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_CredentialCache&issues=AaESDyuPPtOxru3e3Boc&open=AaESDyuPPtOxru3e3Boc&pullRequest=184
}

[SupportedOSPlatform("linux")]
private static void AssertNotTranslated()
{
Assert.AreEqual(42, LinuxSecretServiceCredentialStore.TranslateMissingLibrary(() => 42));
Assert.ThrowsExactly<InvalidOperationException>(
() => LinuxSecretServiceCredentialStore.TranslateMissingLibrary<int>(() => 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<CredentialStoreException>(() => 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<CredentialStoreException>(() => store.TryLoad(persona, out _));
Assert.ThrowsExactly<CredentialStoreException>(() => store.Remove(persona));
Assert.ThrowsExactly<CredentialStoreException>(() => store.Save(persona, new CredentialWithNothing()));
}
}
}
71 changes: 62 additions & 9 deletions CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
/// <para>
/// 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 <see cref="InMemoryCredentialStore"/> if appropriate.
/// this provider throws <see cref="CredentialStoreException"/> from every operation;
/// consumers should catch it and fall back to <see cref="InMemoryCredentialStore"/> if
/// appropriate. A missing <c>libsecret-1.so.0</c> surfaces the same way, with the
/// <see cref="DllNotFoundException"/> as its inner exception.
/// </para>
/// <para>
/// Plaintext credential bytes are scrubbed on the same terms as the Windows and macOS
Expand Down Expand Up @@ -50,11 +52,11 @@

IntPtr error = IntPtr.Zero;
IntPtr passwordPtr = NativeMethods.secret_password_lookup_sync(
Schema.Handle,
GetSchemaHandle(),
IntPtr.Zero,
ref error,
"service", _serviceName,

Check warning on line 58 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'service' 4 times.

Check warning on line 58 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'service' 4 times.

Check warning on line 58 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'service' 4 times.

Check warning on line 58 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'service' 4 times.

Check warning on line 58 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'service' 4 times.
"account", persona.ToString(),

Check warning on line 59 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'account' 4 times.

Check warning on line 59 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'account' 4 times.

Check warning on line 59 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'account' 4 times.

Check warning on line 59 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'account' 4 times.

Check warning on line 59 in CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'account' 4 times.
IntPtr.Zero);

ThrowIfError(error, "secret_password_lookup_sync");
Expand Down Expand Up @@ -94,7 +96,7 @@

IntPtr error = IntPtr.Zero;
bool stored = NativeMethods.secret_password_store_sync(
Schema.Handle,
GetSchemaHandle(),
IntPtr.Zero,
label,
value.Pointer,
Expand All @@ -119,7 +121,7 @@

IntPtr error = IntPtr.Zero;
bool removed = NativeMethods.secret_password_clear_sync(
Schema.Handle,
GetSchemaHandle(),
IntPtr.Zero,
ref error,
"service", _serviceName,
Expand Down Expand Up @@ -153,14 +155,65 @@
throw new CredentialStoreException($"{operation} failed: {message ?? "<no detail>"}");
}

private static class Schema
{
internal static readonly IntPtr Handle = NativeMethods.secret_schema_new(
private static readonly NativeHandleCache Schema = new();

/// <summary>
/// Builds the schema on first use rather than in a type initializer: a load failure
/// there would reach callers as a <see cref="TypeInitializationException"/> on every
/// call, which nobody would think to catch.
/// </summary>
private static IntPtr GetSchemaHandle() =>
Schema.GetOrCreate(() => TranslateMissingLibrary(() => NativeMethods.secret_schema_new(
"dev.ktsu.CredentialCache",
flags: 0,
"service", 0,
"account", 0,
IntPtr.Zero);
IntPtr.Zero)));
Comment on lines +166 to +171

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same finding as the earlier thread on this call. It was re-posted because the call moved into GetSchemaHandle. The answer is the same: secret_schema_new is libsecret, which has no managed equivalent, so the code stays as is.


Generated by Claude Code


/// <summary>
/// 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.
/// </summary>
internal sealed class NativeHandleCache
{
private readonly Lock _lock = new();
private IntPtr _handle;

/// <summary>
/// Returns the cached handle, creating it with <paramref name="create"/> if there is none.
/// </summary>
/// <param name="create">Creates the handle.</param>
/// <returns>The handle.</returns>
internal IntPtr GetOrCreate(Func<IntPtr> create)
{
lock (_lock)
{
if (_handle == IntPtr.Zero)
{
_handle = create();
}

return _handle;
}
}
}

/// <summary>
/// Runs a native call, turning a missing or incompatible libsecret into the store's
/// documented <see cref="CredentialStoreException"/>.
/// </summary>
internal static T TranslateMissingLibrary<T>(Func<T> 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
Expand Down
Loading