diff --git a/WhiteMagic/Thread/FrozenThread.cs b/WhiteMagic/Thread/FrozenThread.cs new file mode 100644 index 0000000..25987e4 --- /dev/null +++ b/WhiteMagic/Thread/FrozenThread.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace WhiteMagic.Thread; + +/// +/// A disposable scope that tracks a set of threads frozen by . +/// Disposing the scope resumes exactly those threads, in reverse order, even if the guarded +/// body throws. +/// +public sealed class FrozenThread : IDisposable +{ + private readonly IReadOnlyList _threads; + private bool _disposed; + + internal FrozenThread(IReadOnlyList threads) + { + _threads = threads ?? throw new ArgumentNullException(nameof(threads)); + } + + /// The threads suspended by this freeze scope. + public IEnumerable Threads => _threads; + + /// + /// Resumes the frozen threads in reverse order. The original call is responsible for + /// disposing the instances afterwards. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + foreach (RemoteThread thread in _threads.Reverse()) + { + try + { + thread.Resume(); + } + catch + { + // Resume-on-dispose is best-effort; callers keep the thread handles so + // they can diagnose or recover separately. + } + } + } +} diff --git a/WhiteMagic/Thread/RemoteThread.cs b/WhiteMagic/Thread/RemoteThread.cs new file mode 100644 index 0000000..d8bd8f4 --- /dev/null +++ b/WhiteMagic/Thread/RemoteThread.cs @@ -0,0 +1,184 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using WhiteMagic.Native; +using WhiteMagic.ThreadEnvironment; + +namespace WhiteMagic.Thread; + +/// +/// A handle to an existing thread in the target process. Provides suspend/resume, +/// context read/write, and TEB query. +/// +public sealed class RemoteThread : IDisposable +{ + private readonly MemoryBase _memory; + private readonly SafeMemoryHandle _handle; + private readonly int _id; + private bool _disposed; + + /// The operating-system identifier of this thread. + public int Id => _id; + + /// The native thread handle. + internal SafeMemoryHandle Handle => _handle; + + internal RemoteThread(MemoryBase memory, int threadId, SafeMemoryHandle handle) + { + _memory = memory ?? throw new ArgumentNullException(nameof(memory)); + _id = threadId; + _handle = handle ?? throw new ArgumentNullException(nameof(handle)); + } + + /// + /// Opens the thread specified by in the target process + /// represented by . + /// + public RemoteThread(MemoryBase memory, int threadId) + : this(memory, threadId, OpenHandle(threadId)) + { + } + + private static SafeMemoryHandle OpenHandle(int threadId) + { + if (threadId <= 0) + throw new ArgumentException("Thread ID must be positive.", nameof(threadId)); + + const ThreadAccess requiredAccess = + ThreadAccess.SuspendResume | + ThreadAccess.GetContext | + ThreadAccess.SetContext | + ThreadAccess.QueryInformation; + + SafeMemoryHandle handle = NativeMethods.OpenThread(requiredAccess, false, threadId); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}."); + } + + return handle; + } + + /// + /// Suspends the thread and returns its previous suspend count. + /// + public uint Suspend() + { + uint result = NativeMethods.SuspendThread(_handle); + if (result == 0xFFFFFFFF) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"SuspendThread failed for thread {_id}: error {error}."); + } + + return result; + } + + /// + /// Resumes the thread and returns its previous suspend count. + /// + public uint Resume() + { + uint result = NativeMethods.ResumeThread(_handle); + if (result == 0xFFFFFFFF) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"ResumeThread failed for thread {_id}: error {error}."); + } + + return result; + } + + /// + /// Reads the 64-bit native context of the thread. Valid only for 64-bit targets. + /// + public unsafe void GetContext64(out Context64 context) + { + nint size = Marshal.SizeOf(); + void* ptr = NativeMemory.AlignedAlloc((nuint)size, 16); + try + { + Unsafe.InitBlock(ptr, 0, (uint)size); + ((Context64*)ptr)->ContextFlags = ContextFlags.Amd64Full; + + if (!NativeMethods.GetThreadContext(_handle, ref *(Context64*)ptr)) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"GetThreadContext failed for thread {_id}: error {error}."); + } + + context = *(Context64*)ptr; + } + finally + { + NativeMemory.AlignedFree(ptr); + } + } + + /// + /// Writes the 64-bit native context of the thread. Valid only for 64-bit targets. + /// + public unsafe void SetContext64(ref Context64 context) + { + nint size = Marshal.SizeOf(); + void* ptr = NativeMemory.AlignedAlloc((nuint)size, 16); + try + { + *(Context64*)ptr = context; + if (!NativeMethods.SetThreadContext(_handle, ref *(Context64*)ptr)) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"SetThreadContext failed for thread {_id}: error {error}."); + } + } + finally + { + NativeMemory.AlignedFree(ptr); + } + } + + /// + /// Reads the 32-bit native context of the thread. Valid for 32-bit targets or + /// WOW64 threads selected by a 64-bit caller. + /// + public void GetContext32(out Context32 context) + { + context = new Context32 { ContextFlags = ContextFlags.X86Full }; + if (!NativeMethods.GetThreadContext(_handle, ref context)) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"GetThreadContext failed for thread {_id}: error {error}."); + } + } + + /// + /// Writes the 32-bit native context of the thread. + /// + public void SetContext32(ref Context32 context) + { + if (!NativeMethods.SetThreadContext(_handle, ref context)) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"SetThreadContext failed for thread {_id}: error {error}."); + } + } + + /// + /// Returns a managed reader for this thread's Thread Environment Block. + /// + public ManagedTeb GetTeb() + { + return new ManagedTeb(_memory, _id); + } + + /// + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + _handle.Dispose(); + } + } +} diff --git a/WhiteMagic/Thread/ThreadFactory.cs b/WhiteMagic/Thread/ThreadFactory.cs new file mode 100644 index 0000000..7faf73e --- /dev/null +++ b/WhiteMagic/Thread/ThreadFactory.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using WhiteMagic.Native; + +namespace WhiteMagic.Thread; + +/// +/// Enumerates and selects threads belonging to the target process. +/// +public sealed class ThreadFactory +{ + private readonly MemoryBase _memory; + + /// Creates a factory bound to the target process represented by . + public ThreadFactory(MemoryBase memory) + { + _memory = memory ?? throw new ArgumentNullException(nameof(memory)); + } + + /// + /// Enumerates every thread that belongs to the target process. + /// + public IEnumerable Enumerate() + { + foreach (int threadId in CollectThreadIds()) + { + SafeMemoryHandle handle = NativeMethods.OpenThread( + ThreadAccess.SuspendResume | + ThreadAccess.GetContext | + ThreadAccess.SetContext | + ThreadAccess.QueryInformation, + false, + threadId); + + if (handle.IsInvalid) + continue; + + yield return new RemoteThread(_memory, threadId, handle); + } + } + + private int[] CollectThreadIds() + { + using SafeMemoryHandle snapshot = NativeMethods.CreateToolhelp32Snapshot(SnapshotFlags.Thread, 0); + if (snapshot.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"CreateToolhelp32Snapshot failed: error {error}."); + } + + var entry = new ThreadEntry32 + { + dwSize = (uint)Marshal.SizeOf() + }; + + var ids = new List(); + + if (!NativeMethods.Thread32First(snapshot, ref entry)) + { + int error = Marshal.GetLastPInvokeError(); + if (error == 18 || error == 259) // ERROR_NO_MORE_FILES / ERROR_NO_MORE_ITEMS + return ids.ToArray(); + + throw new InvalidOperationException($"Thread32First failed: error {error}."); + } + + do + { + if (entry.th32OwnerProcessID == (uint)_memory.ProcessId) + ids.Add((int)entry.th32ThreadID); + } + while (NativeMethods.Thread32Next(snapshot, ref entry)); + + return ids.ToArray(); + } + + /// + /// Returns the thread with the specified operating-system identifier if it belongs + /// to the target process. + /// + /// The thread does not belong to the target process. + public RemoteThread GetThreadById(int threadId) + { + if (threadId <= 0) + throw new ArgumentException("Thread ID must be positive.", nameof(threadId)); + + SafeMemoryHandle handle = NativeMethods.OpenThread(ThreadAccess.QueryInformation, false, threadId); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}."); + } + + try + { + var info = new ThreadBasicInformation(); + int status = NativeMethods.NtQueryInformationThread( + handle, + 0, + ref info, + (uint)Marshal.SizeOf(), + out _); + + if (status < 0) + { + throw new InvalidOperationException( + $"NtQueryInformationThread failed for thread {threadId} (NTSTATUS {status:X8})."); + } + + if ((uint)(nint)info.ClientId.UniqueProcess != (uint)_memory.ProcessId) + { + throw new InvalidOperationException( + $"Thread {threadId} does not belong to process {_memory.ProcessId}."); + } + + // Open a handle with the rights the public RemoteThread surface needs. + return new RemoteThread(_memory, threadId); + } + finally + { + handle.Dispose(); + } + } + + /// + /// Returns the earliest-created thread of the target process. + /// + public RemoteThread MainThread + { + get + { + RemoteThread? earliest = null; + long earliestTime = long.MaxValue; + + foreach (RemoteThread thread in Enumerate()) + { + long creationTime = GetCreationTime(thread.Id); + if (creationTime < earliestTime) + { + earliestTime = creationTime; + earliest?.Dispose(); + earliest = thread; + } + else + { + thread.Dispose(); + } + } + + if (earliest is null) + { + throw new InvalidOperationException( + $"Process {_memory.ProcessId} has no observable threads."); + } + + return earliest; + } + } + + /// + /// Suspends the supplied threads and returns a disposable scope that resumes exactly + /// those threads when disposed, including when an exception escapes the guarded body. + /// + /// + /// Do not freeze the target's threads while executing target code through a remote + /// thread or main-thread pump; doing so can deadlock because the frozen thread is the + /// one responsible for running the code. + /// + public FrozenThread Freeze(IEnumerable threads) + { + ArgumentNullException.ThrowIfNull(threads); + + var suspended = new List(); + try + { + foreach (RemoteThread thread in threads) + { + thread.Suspend(); + suspended.Add(thread); + } + + return new FrozenThread(suspended); + } + catch + { + foreach (RemoteThread thread in suspended) + { + try + { + thread.Resume(); + } + catch + { + // Best-effort unwind. + } + } + + throw; + } + } + + /// + /// Suspends all threads selected by . + /// + public FrozenThread Freeze(Func predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + return Freeze(Enumerate().Where(predicate)); + } + + private long GetCreationTime(int threadId) + { + using SafeMemoryHandle handle = NativeMethods.OpenThread(ThreadAccess.QueryInformation, false, threadId); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"OpenThread failed for thread {threadId}: error {error}."); + } + + if (!NativeMethods.GetThreadTimes(handle, out long creationTime, out _, out _, out _)) + { + int error = Marshal.GetLastPInvokeError(); + throw new InvalidOperationException($"GetThreadTimes failed for thread {threadId}: error {error}."); + } + + return creationTime; + } +} diff --git a/WhiteMagicTest/Thread/FrozenThreadTests.cs b/WhiteMagicTest/Thread/FrozenThreadTests.cs new file mode 100644 index 0000000..9490df1 --- /dev/null +++ b/WhiteMagicTest/Thread/FrozenThreadTests.cs @@ -0,0 +1,192 @@ +using System.Linq; +using System.Threading; +using SysThread = System.Threading.Thread; +using WhiteMagic; +using WhiteMagic.Native; +using WhiteMagic.Thread; +using Xunit; + +namespace WhiteMagicTest.Thread; + +/// +/// Tests for scoped thread freeze via and . +/// +public sealed class FrozenThreadTests +{ + [Fact] + public void Freeze_suspends_selected_workers_until_disposed() + { + using var magic = Magic.OpenInProcess(); + var factory = new ThreadFactory(magic.Memory); + + using var cts1 = new CancellationTokenSource(); + using var cts2 = new CancellationTokenSource(); + var started1 = new ManualResetEventSlim(false); + var started2 = new ManualResetEventSlim(false); + int osThreadId1 = 0; + int osThreadId2 = 0; + + var worker1 = new SysThread(() => + { + osThreadId1 = (int)NativeMethods.GetCurrentThreadId(); + started1.Set(); + while (!cts1.IsCancellationRequested) + SysThread.Sleep(10); + }); + + var worker2 = new SysThread(() => + { + osThreadId2 = (int)NativeMethods.GetCurrentThreadId(); + started2.Set(); + while (!cts2.IsCancellationRequested) + SysThread.Sleep(10); + }); + + worker1.Start(); + worker2.Start(); + started1.Wait(); + started2.Wait(); + + int[] targetIds = [osThreadId1, osThreadId2]; + + try + { + var selected = factory.Enumerate().Where(t => targetIds.Contains(t.Id)).ToList(); + Assert.Equal(2, selected.Count); + + using (factory.Freeze(selected)) + { + cts1.Cancel(); + cts2.Cancel(); + + Assert.False(worker1.Join(100)); + Assert.False(worker2.Join(100)); + } + + Assert.True(worker1.Join(1000)); + Assert.True(worker2.Join(1000)); + } + finally + { + if (worker1.IsAlive) + { + cts1.Cancel(); + using var t = new RemoteThread(magic.Memory, osThreadId1); + t.Resume(); + worker1.Join(1000); + } + + if (worker2.IsAlive) + { + cts2.Cancel(); + using var t = new RemoteThread(magic.Memory, osThreadId2); + t.Resume(); + worker2.Join(1000); + } + } + } + + [Fact] + public void Dispose_resumes_only_frozen_threads_leaving_external_suspends_intact() + { + using var magic = Magic.OpenInProcess(); + var factory = new ThreadFactory(magic.Memory); + + using var cts = new CancellationTokenSource(); + var started = new ManualResetEventSlim(false); + int osThreadId = 0; + + var worker = new SysThread(() => + { + osThreadId = (int)NativeMethods.GetCurrentThreadId(); + started.Set(); + while (!cts.IsCancellationRequested) + SysThread.Sleep(10); + }); + + worker.Start(); + started.Wait(); + + try + { + // Suspend the worker externally first. + using (var external = new RemoteThread(magic.Memory, osThreadId)) + { + external.Suspend(); + + var selected = factory.Enumerate().Where(t => t.Id == osThreadId).ToList(); + using (factory.Freeze(selected)) + { + // Frozen scope adds one more suspend count. + } + + // After the freeze scope disposes, the worker was resumed once. + // Because it was already externally suspended, it should still be suspended. + cts.Cancel(); + Assert.False(worker.Join(100)); + + external.Resume(); + } + + Assert.True(worker.Join(1000)); + } + finally + { + if (worker.IsAlive) + { + cts.Cancel(); + using var t = new RemoteThread(magic.Memory, osThreadId); + t.Resume(); + worker.Join(1000); + } + } + } + + [Fact] + public void Exception_in_body_still_resumes_frozen_threads() + { + using var magic = Magic.OpenInProcess(); + var factory = new ThreadFactory(magic.Memory); + + using var cts = new CancellationTokenSource(); + var started = new ManualResetEventSlim(false); + int osThreadId = 0; + + var worker = new SysThread(() => + { + osThreadId = (int)NativeMethods.GetCurrentThreadId(); + started.Set(); + while (!cts.IsCancellationRequested) + SysThread.Sleep(10); + }); + + worker.Start(); + started.Wait(); + + try + { + var selected = factory.Enumerate().Where(t => t.Id == osThreadId).ToList(); + + Assert.Throws(new Action(() => + { + using (factory.Freeze(selected)) + { + throw new InvalidOperationException("Intentional failure inside freeze scope."); + } + })); + + cts.Cancel(); + Assert.True(worker.Join(1000)); + } + finally + { + if (worker.IsAlive) + { + cts.Cancel(); + using var t = new RemoteThread(magic.Memory, osThreadId); + t.Resume(); + worker.Join(1000); + } + } + } +} diff --git a/WhiteMagicTest/Thread/RemoteThreadContextTests.cs b/WhiteMagicTest/Thread/RemoteThreadContextTests.cs new file mode 100644 index 0000000..aaf8545 --- /dev/null +++ b/WhiteMagicTest/Thread/RemoteThreadContextTests.cs @@ -0,0 +1,131 @@ +using System.Threading; +using Thread = System.Threading.Thread; +using WhiteMagic; +using WhiteMagic.Native; +using WhiteMagic.Thread; +using Xunit; + +namespace WhiteMagicTest.Thread; + +/// +/// Tests for / . +/// 32-bit/WOW64 context is tested on a 32-bit host run. +/// +public sealed class RemoteThreadContextTests +{ + [Fact] + public void GetContext64_SetContext64_round_trip_on_suspended_self_thread() + { + if (!Environment.Is64BitProcess) + return; + + using var magic = Magic.OpenInProcess(); + using var cts = new CancellationTokenSource(); + var started = new ManualResetEventSlim(false); + int osThreadId = 0; + + var worker = new System.Threading.Thread(() => + { + osThreadId = (int)NativeMethods.GetCurrentThreadId(); + started.Set(); + while (!cts.IsCancellationRequested) + System.Threading.Thread.Sleep(10); + }); + + worker.Start(); + started.Wait(); + + try + { + using var thread = new RemoteThread(magic.Memory, osThreadId); + thread.Suspend(); + System.Threading.Thread.Sleep(100); + + thread.GetContext64(out Context64 context); + Assert.NotEqual(0uL, context.Rip); + + const ulong sentinel = 0x123456789ABCDEF0uL; + ulong originalRax = context.Rax; + context.Rax = sentinel; + thread.SetContext64(ref context); + + thread.GetContext64(out context); + Assert.Equal(sentinel, context.Rax); + + // Restore the original register before resuming so the worker keeps running. + context.Rax = originalRax; + thread.SetContext64(ref context); + + thread.Resume(); + cts.Cancel(); + Assert.True(worker.Join(1000)); + } + finally + { + if (worker.IsAlive) + { + cts.Cancel(); + using var thread = new RemoteThread(magic.Memory, osThreadId); + thread.Resume(); + worker.Join(1000); + } + } + } + + [Fact] + public void GetContext32_SetContext32_round_trip_on_suspended_self_thread() + { + if (Environment.Is64BitProcess) + return; + + using var magic = Magic.OpenInProcess(); + using var cts = new CancellationTokenSource(); + var started = new ManualResetEventSlim(false); + int osThreadId = 0; + + var worker = new System.Threading.Thread(() => + { + osThreadId = (int)NativeMethods.GetCurrentThreadId(); + started.Set(); + while (!cts.IsCancellationRequested) + System.Threading.Thread.Sleep(10); + }); + + worker.Start(); + started.Wait(); + + try + { + using var thread = new RemoteThread(magic.Memory, osThreadId); + thread.Suspend(); + + thread.GetContext32(out Context32 context); + Assert.NotEqual(0u, context.Eip); + + const uint sentinel = 0x89ABCDEFu; + uint originalEax = context.Eax; + context.Eax = sentinel; + thread.SetContext32(ref context); + + thread.GetContext32(out context); + Assert.Equal(sentinel, context.Eax); + + context.Eax = originalEax; + thread.SetContext32(ref context); + + thread.Resume(); + cts.Cancel(); + Assert.True(worker.Join(1000)); + } + finally + { + if (worker.IsAlive) + { + cts.Cancel(); + using var thread = new RemoteThread(magic.Memory, osThreadId); + thread.Resume(); + worker.Join(1000); + } + } + } +} diff --git a/WhiteMagicTest/Thread/RemoteThreadTests.cs b/WhiteMagicTest/Thread/RemoteThreadTests.cs new file mode 100644 index 0000000..db14435 --- /dev/null +++ b/WhiteMagicTest/Thread/RemoteThreadTests.cs @@ -0,0 +1,130 @@ +using System.Threading; +using Thread = System.Threading.Thread; +using WhiteMagic; +using WhiteMagic.Native; +using WhiteMagic.Thread; +using Xunit; + +namespace WhiteMagicTest.Thread; + +/// +/// Tests for open/suspend/resume and context round-trip. +/// +public sealed class RemoteThreadTests +{ + [Fact] + public void Open_by_id_succeeds_for_current_thread() + { + using var magic = Magic.OpenInProcess(); + int currentId = (int)NativeMethods.GetCurrentThreadId(); + + using var thread = new RemoteThread(magic.Memory, currentId); + Assert.Equal(currentId, thread.Id); + } + + [Fact] + public void Suspend_returns_prior_count_and_stops_worker() + { + using var magic = Magic.OpenInProcess(); + using var cts = new CancellationTokenSource(); + var started = new ManualResetEventSlim(false); + int osThreadId = 0; + + var worker = new System.Threading.Thread(() => + { + osThreadId = (int)NativeMethods.GetCurrentThreadId(); + started.Set(); + while (!cts.IsCancellationRequested) + System.Threading.Thread.Sleep(10); + }); + + worker.Start(); + started.Wait(); + + try + { + using var thread = new RemoteThread(magic.Memory, osThreadId); + + uint prior = thread.Suspend(); + Assert.True(prior < 0xFFFFFFFF); + + cts.Cancel(); + // Worker cannot observe cancellation while suspended. + Assert.False(worker.Join(100)); + + thread.Resume(); + Assert.True(worker.Join(1000)); + } + finally + { + if (worker.IsAlive) + { + cts.Cancel(); + using var thread = new RemoteThread(magic.Memory, osThreadId); + thread.Resume(); + worker.Join(1000); + } + } + } + + [Fact] + public void Resume_restarts_a_suspended_worker() + { + using var magic = Magic.OpenInProcess(); + using var cts = new CancellationTokenSource(); + var started = new ManualResetEventSlim(false); + var resumed = new ManualResetEventSlim(false); + int osThreadId = 0; + + var worker = new System.Threading.Thread(() => + { + osThreadId = (int)NativeMethods.GetCurrentThreadId(); + started.Set(); + while (!cts.IsCancellationRequested) + { + resumed.Set(); + System.Threading.Thread.Sleep(10); + } + }); + + worker.Start(); + started.Wait(); + + try + { + using var thread = new RemoteThread(magic.Memory, osThreadId); + thread.Suspend(); + resumed.Reset(); + + uint prior = thread.Resume(); + Assert.True(prior < 0xFFFFFFFF); + + // Worker must reach the resumed flag again. + Assert.True(resumed.Wait(1000)); + cts.Cancel(); + Assert.True(worker.Join(1000)); + } + finally + { + if (worker.IsAlive) + { + cts.Cancel(); + using var thread = new RemoteThread(magic.Memory, osThreadId); + thread.Resume(); + worker.Join(1000); + } + } + } + + [Fact] + public void GetTeb_returns_managed_teb_for_thread() + { + using var magic = Magic.OpenInProcess(); + int currentId = (int)NativeMethods.GetCurrentThreadId(); + + using var thread = new RemoteThread(magic.Memory, currentId); + using var teb = thread.GetTeb(); + + Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress()); + } +} diff --git a/WhiteMagicTest/Thread/ThreadFactoryTests.cs b/WhiteMagicTest/Thread/ThreadFactoryTests.cs new file mode 100644 index 0000000..90d3790 --- /dev/null +++ b/WhiteMagicTest/Thread/ThreadFactoryTests.cs @@ -0,0 +1,61 @@ +using System.Linq; +using System.Threading; +using SysThread = System.Threading.Thread; +using WhiteMagic; +using WhiteMagic.Native; +using WhiteMagic.Thread; +using Xunit; + +namespace WhiteMagicTest.Thread; + +/// +/// Tests for enumeration and main-thread selection. +/// +public sealed class ThreadFactoryTests +{ + [Fact] + public void Enumerate_returns_only_target_threads() + { + using var magic = Magic.OpenInProcess(); + var factory = new ThreadFactory(magic.Memory); + + int currentOsId = (int)NativeMethods.GetCurrentThreadId(); + var ids = factory.Enumerate().Select(t => t.Id).ToList(); + + Assert.True(ids.Count > 0); + Assert.Contains(currentOsId, ids); + } + + [Fact] + public void GetThreadById_returns_matching_thread() + { + using var magic = Magic.OpenInProcess(); + var factory = new ThreadFactory(magic.Memory); + + int currentOsId = (int)NativeMethods.GetCurrentThreadId(); + using RemoteThread thread = factory.GetThreadById(currentOsId); + Assert.Equal(currentOsId, thread.Id); + } + + [Fact] + public void GetThreadById_throws_for_nonexistent_thread() + { + using var magic = Magic.OpenInProcess(); + var factory = new ThreadFactory(magic.Memory); + + Assert.Throws(() => factory.GetThreadById(0x7FFFFFFF)); + } + + [Fact] + public void MainThread_returns_a_thread_belonging_to_the_target() + { + using var magic = Magic.OpenInProcess(); + var factory = new ThreadFactory(magic.Memory); + + using RemoteThread main = factory.MainThread; + Assert.NotNull(main); + + var ids = factory.Enumerate().Select(t => t.Id).ToList(); + Assert.Contains(main.Id, ids); + } +}