diff --git a/CredentialCache.Test/GErrorTests.cs b/CredentialCache.Test/GErrorTests.cs new file mode 100644 index 0000000..aa3edf4 --- /dev/null +++ b/CredentialCache.Test/GErrorTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CredentialCache.Test; + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using ktsu.CredentialCache.Storage; + +/// +/// Covers reading the message out of a glib GError, which the Linux store does on +/// every libsecret failure. The GError here is hand-built, so this runs on every platform. +/// +[TestClass] +public class GErrorTests +{ + // GError is { guint32 domain; gint code; gchar *message; }, so the message pointer + // sits at offset 8 on both 32-bit and 64-bit, with no padding before it. + private const int MessageOffset = sizeof(uint) + sizeof(int); + + [TestMethod] + public void ReadMessageReturnsTheMessageField() + { + IntPtr message = Marshal.StringToCoTaskMemUTF8("Cannot autolaunch D-Bus without X11 $DISPLAY"); + + // One pointer longer than the struct and zeroed, so a read past the end of the struct + // finds a null pointer rather than whatever happens to follow the allocation. + int size = MessageOffset + (IntPtr.Size * 2); + IntPtr error = Marshal.AllocHGlobal(size); + try + { + Marshal.Copy(new byte[size], 0, error, size); + Marshal.WriteInt32(error, 0, 42); + Marshal.WriteInt32(error, sizeof(uint), 7); + Marshal.WriteIntPtr(error, MessageOffset, message); + + Assert.AreEqual("Cannot autolaunch D-Bus without X11 $DISPLAY", GError.ReadMessage(error)); + } + finally + { + Marshal.FreeHGlobal(error); + Marshal.FreeCoTaskMem(message); + } + } + + [TestMethod] + public void ReadMessageReturnsNullForANullMessage() + { + int size = MessageOffset + (IntPtr.Size * 2); + IntPtr error = Marshal.AllocHGlobal(size); + try + { + Marshal.Copy(new byte[size], 0, error, size); + + Assert.IsNull(GError.ReadMessage(error)); + } + finally + { + Marshal.FreeHGlobal(error); + } + } + + [TestMethod] + public void ThrowIfErrorReportsTheMessageOfARealGError() + { + if (!OperatingSystem.IsLinux()) + { + Assert.Inconclusive("GError comes from glib, which is only loaded on Linux."); + return; + } + + AssertThrowIfErrorReportsTheMessage(); + } + + // Built by glib itself rather than by hand, so this checks the real layout. glib frees + // the error inside ThrowIfError. + [SupportedOSPlatform("linux")] + private static void AssertThrowIfErrorReportsTheMessage() + { + IntPtr glib = NativeLibrary.Load("libglib-2.0.so.0"); + GErrorNewLiteral newLiteral = Marshal.GetDelegateForFunctionPointer( + NativeLibrary.GetExport(glib, "g_error_new_literal")); + IntPtr error = newLiteral(1, 2, "Cannot autolaunch D-Bus without X11 $DISPLAY"); + + CredentialStoreException exception = Assert.ThrowsExactly( + () => LinuxSecretServiceCredentialStore.ThrowIfError(error, "secret_password_lookup_sync")); + + Assert.AreEqual("secret_password_lookup_sync failed: Cannot autolaunch D-Bus without X11 $DISPLAY", exception.Message); + } + + private delegate IntPtr GErrorNewLiteral(uint domain, int code, [MarshalAs(UnmanagedType.LPUTF8Str)] string message); +} diff --git a/CredentialCache/Storage/GError.cs b/CredentialCache/Storage/GError.cs new file mode 100644 index 0000000..d5efe1a --- /dev/null +++ b/CredentialCache/Storage/GError.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CredentialCache.Storage; + +using System.Runtime.InteropServices; + +/// +/// glib's GError: { guint32 domain; gint code; gchar *message; }. +/// +/// +/// Declared rather than read at a hand-computed offset. The message pointer follows two +/// 4-byte fields, so it sits at offset 8 on both 32-bit and 64-bit; an offset derived from +/// reads past the end of the struct on 64-bit. +/// +[StructLayout(LayoutKind.Sequential)] +internal readonly struct GError +{ + internal readonly uint Domain; + internal readonly int Code; + internal readonly IntPtr Message; + + /// + /// Reads the message of the GError at . + /// + /// A non-null pointer to a GError. + /// The message, or if the error has none. + internal static string? ReadMessage(IntPtr error) => + Marshal.PtrToStringUTF8(Marshal.PtrToStructure(error).Message); +} diff --git a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs index 6e1b930..e4e7079 100644 --- a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs +++ b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs @@ -130,7 +130,7 @@ public bool Remove(PersonaGUID persona) return removed; } - private static void ThrowIfError(IntPtr error, string operation) + internal static void ThrowIfError(IntPtr error, string operation) { if (error == IntPtr.Zero) { @@ -139,8 +139,7 @@ private static void ThrowIfError(IntPtr error, string operation) string? message = null; try { - IntPtr messagePtr = Marshal.ReadIntPtr(error, IntPtr.Size * 2); - message = Marshal.PtrToStringUTF8(messagePtr); + message = GError.ReadMessage(error); } catch {