File: System\Private\Windows\Ole\NativeToManagedAdapterTests.cs
Web Access
Project: src\src\System.Private.Windows.Core\tests\System.Private.Windows.Core.Tests\System.Private.Windows.Core.Tests.csproj (System.Private.Windows.Core.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.ComponentModel;
using System.Formats.Nrbf;
using System.Private.Windows.BinaryFormat;
using System.Text.Json;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.System.Com;
using Windows.Win32.System.Memory;
 
using Composition = System.Private.Windows.Ole.Composition<
    System.Private.Windows.Ole.MockOleServices<System.Private.Windows.Ole.NativeToManagedAdapterTests>,
    System.Private.Windows.Nrbf.CoreNrbfSerializer,
    System.Private.Windows.Ole.TestFormat>;
using DataFormats = System.Private.Windows.Ole.DataFormatsCore<System.Private.Windows.Ole.TestFormat>;
 
namespace System.Private.Windows.Ole;
 
public unsafe class NativeToManagedAdapterTests
{
    private static readonly byte[] s_serializedObjectID =
    [
        // FD9EA796-3B13-4370-A679-56106BB288FB
        0x96, 0xa7, 0x9e, 0xfd,
        0x13, 0x3b,
        0x70, 0x43,
        0xa6, 0x79, 0x56, 0x10, 0x6b, 0xb2, 0x88, 0xfb
    ];
 
    private readonly TestFormat _format = DataFormats.GetOrAddFormat(nameof(NativeToManagedAdapterTests));
 
    [Fact]
    public void GetData_CustomType_RawData()
    {
        MemoryStream stream = new([0xBE, 0xAD]);
        using HGlobalNativeDataObject dataObject = new(stream, (ushort)_format.Id);
 
        // Composition will finalize the data object.
        var composition = Composition.Create(ComHelpers.GetComPointer<IDataObject>(dataObject));
        object? data = composition.GetData(nameof(NativeToManagedAdapterTests));
        MemoryStream result = data.Should().BeOfType<MemoryStream>().Subject;
        result.ToArray().Should().Equal(0xBE, 0xAD);
    }
 
    [Fact]
    public void GetData_CustomType_RawData_WithPrefix()
    {
        // Write the serialized prefix to the stream.
        MemoryStream stream = new();
        stream.Write(s_serializedObjectID);
        stream.Write([0xBE, 0xAD]);
 
        using HGlobalNativeDataObject dataObject = new(stream, (ushort)_format.Id);
 
        // Composition will finalize the data object.
        var composition = Composition.Create(ComHelpers.GetComPointer<IDataObject>(dataObject));
        object? data = composition.GetData(nameof(NativeToManagedAdapterTests));
 
        // This is now considered a BinaryFormatter serialized object, the data we gave is invalid.
        data.Should().BeNull();
    }
 
    [Fact]
    public void GetData_CustomType_BinaryFormattedData()
    {
        // Write the serialized prefix to the stream.
        MemoryStream stream = new();
        stream.Write(s_serializedObjectID);
        stream.WriteBinaryFormat(new int[] { 0xBE, 0xAD });
 
        using HGlobalNativeDataObject dataObject = new(stream, (ushort)_format.Id);
 
        // Composition will finalize the data object.
        var composition = Composition.Create(ComHelpers.GetComPointer<IDataObject>(dataObject));
        object? data = composition.GetData(nameof(NativeToManagedAdapterTests));
 
        int[] result = data.Should().BeOfType<int[]>().Subject;
        result.Should().Equal(0xBE, 0xAD);
    }
 
    [Fact]
    public void GetData_CustomType_BinaryFormattedData_AsSerializationRecord()
    {
        // Write the serialized prefix to the stream.
        MemoryStream stream = new();
        stream.Write(s_serializedObjectID);
        stream.WriteBinaryFormat(new int[] { 0xBE, 0xAD });
 
        using HGlobalNativeDataObject dataObject = new(stream, (ushort)_format.Id);
 
        // Composition will finalize the data object.
        var composition = Composition.Create(ComHelpers.GetComPointer<IDataObject>(dataObject));
        composition.TryGetData(nameof(NativeToManagedAdapterTests), out SerializationRecord? data).Should().BeTrue();
        data!.TypeName.AssemblyQualifiedName.Should().Be("System.Int32[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089");
    }
 
    [Fact]
    public void GetData_CustomType_BinaryFormattedJson_AsSerializationRecord()
    {
        // Write the serialized prefix to the stream.
        MemoryStream stream = new();
        stream.Write(s_serializedObjectID);
        BinaryFormatWriter.TryWriteJsonData(stream, new JsonData<int[]>() { JsonBytes = JsonSerializer.SerializeToUtf8Bytes(new int[] { 0xBE, 0xAD }) });
 
        using HGlobalNativeDataObject dataObject = new(stream, (ushort)_format.Id);
 
        // Composition will finalize the data object.
        var composition = Composition.Create(ComHelpers.GetComPointer<IDataObject>(dataObject));
        composition.TryGetData(nameof(NativeToManagedAdapterTests), out SerializationRecord? data).Should().BeTrue();
        data!.TypeName.AssemblyQualifiedName.Should().Be("System.Private.Windows.JsonData, System.Private.Windows.VirtualJson");
    }
 
    [Theory]
    [BoolData]
    public void ReadStringFromHGLOBAL_InvalidHGLOBAL_Throws(bool unicode)
    {
        Type type = typeof(Composition).GetFullNestedType("NativeToManagedAdapter");
 
        Action action = () =>
        {
            string result = type.TestAccessor.Dynamic.ReadStringFromHGLOBAL(HGLOBAL.Null, unicode);
        };
 
        action.Should().Throw<Win32Exception>().And.HResult.Should().Be((int)HRESULT.E_FAIL);
    }
 
    [Theory]
    [BoolData]
    public void ReadStringFromHGLOBAL_NoTerminator_ReturnsEmptyString(bool unicode)
    {
        Type type = typeof(Composition).GetFullNestedType("NativeToManagedAdapter");
 
        // There is no way to create a zero-length HGLOBAL, GlobalAlloc will always allocate at least some memory.
        HGLOBAL global = PInvokeCore.GlobalAlloc(GLOBAL_ALLOC_FLAGS.GMEM_MOVEABLE, 6);
        nuint size = PInvokeCore.GlobalSize(global);
 
        try
        {
            using (GlobalBuffer buffer = new(global, (uint)size))
            {
                Span<byte> span = buffer.AsSpan();
                // Fill spaces or daggers
                span.Fill(0x20);
            }
 
            string result = type.TestAccessor.Dynamic.ReadStringFromHGLOBAL(global, unicode);
            result.Should().BeEmpty();
        }
        finally
        {
            PInvokeCore.GlobalFree(global);
        }
    }
 
    [Theory]
    [BoolData]
    public void ReadStringFromHGLOBAL_Terminator_ReturnsString(bool unicode)
    {
        Type type = typeof(Composition).GetFullNestedType("NativeToManagedAdapter");
 
        // There is no way to create a zero-length HGLOBAL, GlobalAlloc will always allocate at least some memory.
        HGLOBAL global = PInvokeCore.GlobalAlloc(GLOBAL_ALLOC_FLAGS.GMEM_MOVEABLE | GLOBAL_ALLOC_FLAGS.GMEM_ZEROINIT, 6);
        nuint size = PInvokeCore.GlobalSize(global);
 
        try
        {
            using (GlobalBuffer buffer = new(global, (uint)size))
            {
                Span<byte> span = buffer.AsSpan();
                // Fill spaces or daggers, leave the last two bytes as zero
                span[..^2].Fill(0x20);
            }
 
            string result = type.TestAccessor.Dynamic.ReadStringFromHGLOBAL(global, unicode);
            result.Should().NotBeEmpty();
        }
        finally
        {
            PInvokeCore.GlobalFree(global);
        }
    }
 
    [Fact]
    public void ReadUtf8StringFromHGLOBAL_InvalidHGLOBAL_Throws()
    {
        Type type = typeof(Composition).GetFullNestedType("NativeToManagedAdapter");
 
        Action action = () =>
        {
            string result = type.TestAccessor.Dynamic.ReadUtf8StringFromHGLOBAL(HGLOBAL.Null);
        };
 
        action.Should().Throw<Win32Exception>().And.HResult.Should().Be((int)HRESULT.E_FAIL);
    }
 
    [Fact]
    public void ReadUtf8StringFromHGLOBAL_NoTerminator_ReturnsEmptyString()
    {
        Type type = typeof(Composition).GetFullNestedType("NativeToManagedAdapter");
 
        HGLOBAL global = PInvokeCore.GlobalAlloc(GLOBAL_ALLOC_FLAGS.GMEM_MOVEABLE, 6);
        nuint size = PInvokeCore.GlobalSize(global);
 
        try
        {
            using (GlobalBuffer buffer = new(global, (uint)size))
            {
                Span<byte> span = buffer.AsSpan();
                // Fill with non-null bytes (UTF-8 compatible ASCII)
                span.Fill(0x41); // 'A'
            }
 
            string result = type.TestAccessor.Dynamic.ReadUtf8StringFromHGLOBAL(global);
 
            // Should return empty string when no null terminator is found (consistent with ReadStringFromHGLOBAL)
            result.Should().BeEmpty();
        }
        finally
        {
            PInvokeCore.GlobalFree(global);
        }
    }
 
    [Fact]
    public void ReadUtf8StringFromHGLOBAL_WithTerminator_ReturnsString()
    {
        Type type = typeof(Composition).GetFullNestedType("NativeToManagedAdapter");
 
        HGLOBAL global = PInvokeCore.GlobalAlloc(GLOBAL_ALLOC_FLAGS.GMEM_MOVEABLE | GLOBAL_ALLOC_FLAGS.GMEM_ZEROINIT, 6);
        nuint size = PInvokeCore.GlobalSize(global);
 
        try
        {
            using (GlobalBuffer buffer = new(global, (uint)size))
            {
                Span<byte> span = buffer.AsSpan();
                // Write "Hello" with null terminator
                span[0] = (byte)'H';
                span[1] = (byte)'e';
                span[2] = (byte)'l';
                span[3] = (byte)'l';
                span[4] = (byte)'o';
                span[5] = 0; // null terminator
            }
 
            string result = type.TestAccessor.Dynamic.ReadUtf8StringFromHGLOBAL(global);
            result.Should().Be("Hello");
        }
        finally
        {
            PInvokeCore.GlobalFree(global);
        }
    }
 
    [Fact]
    public void ReadUtf8StringFromHGLOBAL_WithEarlyTerminator_ReturnsPartialString()
    {
        Type type = typeof(Composition).GetFullNestedType("NativeToManagedAdapter");
 
        HGLOBAL global = PInvokeCore.GlobalAlloc(GLOBAL_ALLOC_FLAGS.GMEM_MOVEABLE, 10);
        nuint size = PInvokeCore.GlobalSize(global);
 
        try
        {
            using (GlobalBuffer buffer = new(global, (uint)size))
            {
                Span<byte> span = buffer.AsSpan();
                // Write "Hi" with null terminator in the middle, followed by garbage
                span[0] = (byte)'H';
                span[1] = (byte)'i';
                span[2] = 0; // null terminator
                span[3] = (byte)'X'; // garbage after terminator
                span[4] = (byte)'Y';
            }
 
            string result = type.TestAccessor.Dynamic.ReadUtf8StringFromHGLOBAL(global);
 
            // Should stop at the first null terminator
            result.Should().Be("Hi");
        }
        finally
        {
            PInvokeCore.GlobalFree(global);
        }
    }
 
    [Fact]
    public void TryGetIStreamData_DoesNotDoubleReleaseStream()
    {
        // This test verifies the fix for double-releasing the IStream.
        // https://github.com/dotnet/wpf/issues/11401
        //
        // Previously, the code wrapped the IStream in a ComScope which would Release it,
        // and then ReleaseStgMedium would also try to Release it, causing a double-release.
 
        MemoryStream stream = new([0xBE, 0xAD, 0xCA, 0xFE]);
        using IStreamNativeDataObject dataObject = new(stream, (ushort)_format.Id);
 
        IDataObject* pDataObject = ComHelpers.GetComPointer<IDataObject>(dataObject);
 
        // GetComPointer returns a pointer with ref count of 1.
        // Add an extra reference so we can track the ref count throughout the test.
        uint initialRefCount = pDataObject->AddRef();
        initialRefCount.Should().Be(2);
 
        object? data;
        uint refCountBeforeGetData;
        uint refCountAfterGetData;
 
        // Scope the Composition so we can observe ref count changes.
        {
            // Composition.Create calls AddRef twice (once for NativeToManagedAdapter, once for NativeToRuntimeAdapter)
            // and takes ownership of the original ref from GetComPointer.
            var composition = Composition.Create(pDataObject);
 
            // After Create: original(1) + our AddRef(1) + Composition's two AddRefs(2) = 4
            refCountBeforeGetData = pDataObject->AddRef();
            pDataObject->Release(); // Undo our test AddRef
 
            // Try to get data - this should not crash due to CFG violation
            // The IStream data object will return data via TYMED_ISTREAM
            data = composition.GetData(nameof(NativeToManagedAdapterTests));
 
            // Verify data was retrieved successfully
            data.Should().BeOfType<MemoryStream>();
 
            // Check ref count after GetData - should be unchanged from before
            // (the IStream from GetData should be properly released by ReleaseStgMedium only once)
            refCountAfterGetData = pDataObject->AddRef();
            pDataObject->Release(); // Undo our test AddRef
 
            refCountAfterGetData.Should().Be(
                refCountBeforeGetData,
                "GetData should not leak or double-release the IDataObject");
        }
 
        // Note: Composition doesn't implement IDisposable, so refs are released via GC.
        // We still hold our extra ref, so the object won't be collected.
 
        // Release our extra ref from the start of the test.
        uint finalRefCount = pDataObject->Release();
 
        // We should still have refs from Composition's adapters (they're not disposed yet).
        // The important thing is that GetData didn't corrupt the ref count.
        finalRefCount.Should().BeGreaterThan(0);
 
        MemoryStream result = (MemoryStream)data!;
        result.ToArray().Should().Equal(0xBE, 0xAD, 0xCA, 0xFE);
    }
 
    [Fact]
    public void GetData_WhenGetDataFails_ReturnsNullInsteadOfCorruptedData()
    {
        // This test verifies the fix for returning corrupted data when GetData fails.
        // https://github.com/dotnet/wpf/issues/11402
        //
        // Previously, if IDataObject::GetData returned a failure HRESULT, the code would still
        // try to read from the STGMEDIUM which could contain uninitialized data, leading to:
        // - Empty strings (if garbage memory happened to have null at the start)
        // - Mojibake/corrupted text (if garbage memory was interpreted as characters)
        //
        // This can happen in practice when there's clipboard contention - QueryGetData succeeds
        // but GetData fails because another application modified the clipboard in between.
 
        using FailingGetDataNativeDataObject dataObject = new((ushort)_format.Id);
 
        var composition = Composition.Create(ComHelpers.GetComPointer<IDataObject>(dataObject));
 
        // GetData should return null when the underlying GetData call fails,
        // not corrupted data from uninitialized memory.
        object? data = composition.GetData(nameof(NativeToManagedAdapterTests));
 
        // Should return null, not corrupted data
        data.Should().BeNull();
    }
}