Skip to content
Open
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
9 changes: 8 additions & 1 deletion .github/workflows/build-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ on:

jobs:
build:
runs-on: windows-latest
# Pinned to windows-2022 for Visual Studio 2022. Do not use windows-latest or
# windows-2025: both now ship Visual Studio 2026, which the CMake version pinned
# below (3.29) cannot generate for - the 'Visual Studio 18 2026' generator was only
# added in CMake 4.2. Without a VS it recognises, CMake silently falls back to the
# NMake Makefiles generator and configuration fails with 'nmake: no such file or
# directory'. Bumping CMake to >= 4.2 is the alternative; keep the runner image and
# the CMake version pinned together either way.
runs-on: windows-2022
strategy:
matrix:
config: [Debug, Release]
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/increment-version.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,14 @@ jobs:

build-and-release:
needs: increment-version
runs-on: windows-latest
# Pinned to windows-2022 for Visual Studio 2022. Do not use windows-latest or
# windows-2025: both now ship Visual Studio 2026, which the CMake version pinned
# below (3.29) cannot generate for - the 'Visual Studio 18 2026' generator was only
# added in CMake 4.2. Without a VS it recognises, CMake silently falls back to the
# NMake Makefiles generator and configuration fails with 'nmake: no such file or
# directory'. Bumping CMake to >= 4.2 is the alternative; keep the runner image and
# the CMake version pinned together either way.
runs-on: windows-2022
permissions:
contents: write
actions: read
Expand Down
108 changes: 108 additions & 0 deletions dotnet/WinDevicesNet.Tests/Utf8MarshalingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Runtime.InteropServices;
using System.Text;
using FluentAssertions;
using WinDevices.Net.Interop;
using Xunit;

namespace WinDevicesNet.Tests;

/// <summary>
/// Regression tests for the UTF-8 string boundary between the native C API and .NET.
///
/// The native library writes every WD_DEVICE_INFO char[] field as UTF-8. The managed
/// struct previously declared those fields as CharSet.Ansi/ByValTStr, so the CLR decoded
/// them with the system ANSI codepage and mangled every non-ASCII character
/// (github.com/TorinKS/WinDeviceslib issue #1).
/// </summary>
public class Utf8MarshalingTests
{
private static byte[] Buffer(string text, int size)
{
var buffer = new byte[size];
Encoding.UTF8.GetBytes(text).CopyTo(buffer, 0);
return buffer;
}

[Theory]
[InlineData("Billboard-Gerät")] // the exact product string from issue #1
[InlineData("Größe")]
[InlineData("Müller Präzision GmbH")]
[InlineData("Ünïcödé")]
public void ToStringZ_WithNonAsciiText_RoundTripsExactly(string original)
{
var decoded = Utf8Buffer.ToStringZ(Buffer(original, 256));

decoded.Should().Be(original);
}

[Fact]
public void ToStringZ_DoesNotProduceAnsiMojibake()
{
// What the old CharSet.Ansi marshalling produced on a Western-European codepage.
var utf8 = Encoding.UTF8.GetBytes("Billboard-Gerät");
var mojibake = Encoding.Latin1.GetString(utf8);
mojibake.Should().Be("Billboard-Gerät", "this is the corruption reported in issue #1");

Utf8Buffer.ToStringZ(Buffer("Billboard-Gerät", 256))
.Should().Be("Billboard-Gerät").And.NotBe(mojibake);
}

[Fact]
public void ToStringZ_StopsAtNulAndIgnoresTrailingBytes()
{
var buffer = Buffer("Gerät", 32);
buffer[Encoding.UTF8.GetByteCount("Gerät") + 3] = 0x41; // stale byte past the terminator

Utf8Buffer.ToStringZ(buffer).Should().Be("Gerät");
}

[Fact]
public void ToStringZ_WithoutNulTerminator_DecodesWholeBuffer()
{
Utf8Buffer.ToStringZ(Encoding.UTF8.GetBytes("Gerät")).Should().Be("Gerät");
}

[Theory]
[InlineData(null)]
[InlineData(new byte[0])]
[InlineData(new byte[] { 0, 0, 0, 0 })]
public void ToStringZ_WithEmptyInput_ReturnsEmptyString(byte[]? buffer)
{
Utf8Buffer.ToStringZ(buffer).Should().BeEmpty();
}

[Fact]
public void ToStringZ_WithMalformedUtf8_DoesNotThrow()
{
// A lone continuation byte - must degrade to U+FFFD rather than fail enumeration.
var act = () => Utf8Buffer.ToStringZ(new byte[] { 0xC3, 0x00 });

act.Should().NotThrow();
}

/// <summary>
/// Guards the claim that swapping ByValTStr for byte buffers kept the struct
/// binary-compatible with the native WD_DEVICE_INFO.
/// </summary>
[Fact]
public void WdDeviceInfo_LayoutMatchesNativeStruct()
{
Marshal.SizeOf<NativeMethods.WdDeviceInfo>().Should().Be(2672);

Offset("Manufacturer").Should().Be(0);
Offset("Product").Should().Be(256);
Offset("SerialNumber").Should().Be(512);
Offset("Description").Should().Be(768);
Offset("DeviceId").Should().Be(1024);
Offset("FriendlyName").Should().Be(1536);
Offset("DevicePath").Should().Be(1792);
Offset("VendorId").Should().Be(2304);
Offset("DeviceClassGuid").Should().Be(2336);
Offset("VendorName").Should().Be(2352);
Offset("ProductName").Should().Be(2480);
Offset("InterfaceClassName").Should().Be(2608);

static int Offset(string field) =>
Marshal.OffsetOf<NativeMethods.WdDeviceInfo>(field).ToInt32();
}
}
20 changes: 10 additions & 10 deletions dotnet/WinDevicesNet/DeviceInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,13 @@ internal static DeviceInfo FromNative(Interop.NativeMethods.WdDeviceInfo native)

return new DeviceInfo
{
Manufacturer = native.Manufacturer ?? string.Empty,
Product = native.Product ?? string.Empty,
SerialNumber = native.SerialNumber ?? string.Empty,
Description = native.Description ?? string.Empty,
DeviceId = native.DeviceId ?? string.Empty,
FriendlyName = native.FriendlyName ?? string.Empty,
DevicePath = native.DevicePath ?? string.Empty,
Manufacturer = Interop.Utf8Buffer.ToStringZ(native.Manufacturer),
Product = Interop.Utf8Buffer.ToStringZ(native.Product),
SerialNumber = Interop.Utf8Buffer.ToStringZ(native.SerialNumber),
Description = Interop.Utf8Buffer.ToStringZ(native.Description),
DeviceId = Interop.Utf8Buffer.ToStringZ(native.DeviceId),
FriendlyName = Interop.Utf8Buffer.ToStringZ(native.FriendlyName),
DevicePath = Interop.Utf8Buffer.ToStringZ(native.DevicePath),
VendorId = native.VendorId,
ProductId = native.ProductId,
DeviceClass = native.DeviceClass,
Expand All @@ -147,9 +147,9 @@ internal static DeviceInfo FromNative(Interop.NativeMethods.WdDeviceInfo native)
IsUsbDevice = native.IsUsbDevice != 0,
DeviceClassGuid = deviceClassGuid,
DeviceClassName = DeviceClassGuids.GetClassName(deviceClassGuid),
VendorName = native.VendorName ?? string.Empty,
ProductName = native.ProductName ?? string.Empty,
InterfaceClassName = native.InterfaceClassName ?? string.Empty
VendorName = Interop.Utf8Buffer.ToStringZ(native.VendorName),
ProductName = Interop.Utf8Buffer.ToStringZ(native.ProductName),
InterfaceClassName = Interop.Utf8Buffer.ToStringZ(native.InterfaceClassName)
};
}
}
2 changes: 1 addition & 1 deletion dotnet/WinDevicesNet/DeviceManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ public static (int Major, int Minor, int Patch, string BuildDate) GetVersion()
string buildDate = string.Empty;
if (versionInfo.BuildDate != IntPtr.Zero)
{
buildDate = Marshal.PtrToStringAnsi(versionInfo.BuildDate) ?? string.Empty;
buildDate = Marshal.PtrToStringUTF8(versionInfo.BuildDate) ?? string.Empty;
}

return (versionInfo.Major, versionInfo.Minor, versionInfo.Patch, buildDate);
Expand Down
71 changes: 47 additions & 24 deletions dotnet/WinDevicesNet/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,29 +58,47 @@ public Guid ToGuid()
}
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
/// <summary>
/// Mirrors the native WD_DEVICE_INFO struct.
/// </summary>
/// <remarks>
/// The native char[] fields are UTF-8, not ANSI (see WinDevicesAPI.h). They are marshalled
/// as raw byte buffers and decoded explicitly via <see cref="Utf8Buffer.ToStringZ"/>; using
/// CharSet.Ansi with ByValTStr here would decode them in the system ANSI codepage and
/// mangle every non-ASCII character. Field order and sizes must match the native struct
/// exactly - byte buffers occupy the same space as the ByValTStr fields they replaced,
/// so the layout is unchanged and remains binary-compatible.
/// </remarks>
[StructLayout(LayoutKind.Sequential)]
public struct WdDeviceInfo
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string Manufacturer;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256, ArraySubType = UnmanagedType.U1)]
public byte[] Manufacturer;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string Product;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256, ArraySubType = UnmanagedType.U1)]
public byte[] Product;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string SerialNumber;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256, ArraySubType = UnmanagedType.U1)]
public byte[] SerialNumber;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string Description;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256, ArraySubType = UnmanagedType.U1)]
public byte[] Description;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 512)]
public string DeviceId;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 512, ArraySubType = UnmanagedType.U1)]
public byte[] DeviceId;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string FriendlyName;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256, ArraySubType = UnmanagedType.U1)]
public byte[] FriendlyName;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 512)]
public string DevicePath;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 512, ArraySubType = UnmanagedType.U1)]
public byte[] DevicePath;

public uint VendorId;
public uint ProductId;
Expand All @@ -97,23 +115,27 @@ public struct WdDeviceInfo

public WdGuid DeviceClassGuid;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string VendorName;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 128, ArraySubType = UnmanagedType.U1)]
public byte[] VendorName;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string ProductName;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 128, ArraySubType = UnmanagedType.U1)]
public byte[] ProductName;

[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string InterfaceClassName;
/// <summary>UTF-8 bytes, NUL-terminated. Decode with <see cref="Utf8Buffer.ToStringZ"/>.</summary>
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 64, ArraySubType = UnmanagedType.U1)]
public byte[] InterfaceClassName;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
[StructLayout(LayoutKind.Sequential)]
public struct WdVersionInfo
{
public int Major;
public int Minor;
public int Patch;
public IntPtr BuildDate; // const char* - will need to marshal
/// <summary>const char* - UTF-8, decoded via Marshal.PtrToStringUTF8.</summary>
public IntPtr BuildDate;
}

#endregion
Expand Down Expand Up @@ -150,7 +172,8 @@ public struct WdVersionInfo
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern WdResult WD_GetVersion(out WdVersionInfo versionInfo);

[DllImport(DllName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
/// <summary>Returns a const char* to a static UTF-8 message; decode with Marshal.PtrToStringUTF8.</summary>
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr WD_GetErrorMessage(WdResult errorCode);

#endregion
Expand Down
39 changes: 39 additions & 0 deletions dotnet/WinDevicesNet/Utf8Buffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Text;

namespace WinDevices.Net.Interop;

/// <summary>
/// Helpers for decoding the fixed-size UTF-8 buffers used by the native WinDevices C API.
/// </summary>
/// <remarks>
/// Every <c>char[]</c> field in <c>WD_DEVICE_INFO</c> holds UTF-8 (see WinDevicesAPI.h),
/// not text in the system ANSI codepage. Decoding these buffers with the ANSI codepage
/// corrupts all non-ASCII characters, so they are marshalled as raw bytes and decoded here.
/// </remarks>
internal static class Utf8Buffer
{
/// <summary>
/// Decodes a NUL-terminated UTF-8 buffer into a string.
/// </summary>
/// <param name="buffer">Fixed-size buffer marshalled from native code; may be null.</param>
/// <returns>
/// The decoded text up to the first NUL, or <see cref="string.Empty"/> when the buffer is
/// null or starts with a NUL. If no NUL is present the whole buffer is decoded.
/// </returns>
/// <remarks>
/// Uses the replacement-character fallback rather than throwing, so a malformed buffer
/// from a mismatched native build degrades gracefully instead of failing enumeration.
/// </remarks>
public static string ToStringZ(byte[]? buffer)
{
if (buffer is null || buffer.Length == 0)
return string.Empty;

int length = Array.IndexOf<byte>(buffer, 0);
if (length < 0)
length = buffer.Length;

return length == 0 ? string.Empty : Encoding.UTF8.GetString(buffer, 0, length);
}
}
2 changes: 1 addition & 1 deletion dotnet/WinDevicesNet/WinDevicesException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ private static string GetErrorMessage(NativeMethods.WdResult errorCode)
var ptr = NativeMethods.WD_GetErrorMessage(errorCode);
if (ptr != IntPtr.Zero)
{
var msg = System.Runtime.InteropServices.Marshal.PtrToStringAnsi(ptr);
var msg = System.Runtime.InteropServices.Marshal.PtrToStringUTF8(ptr);
if (!string.IsNullOrEmpty(msg))
return msg;
}
Expand Down
5 changes: 5 additions & 0 deletions dotnet/WinDevicesNet/WinDevicesNet.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
<Description>.NET wrapper for WinDevices USB enumeration library</Description>
</PropertyGroup>

<ItemGroup>
<!-- The UTF-8 marshalling helper and the native struct layout are internal but must be unit-tested. -->
<InternalsVisibleTo Include="WinDevicesNet.Tests" />
</ItemGroup>

<PropertyGroup Condition="'$(Configuration)'=='Release'">
<Optimize>true</Optimize>
</PropertyGroup>
Expand Down
Loading
Loading