Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,182 @@
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using Xunit;
using XWorld.PublishTool;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
/// <summary>
/// 设计§7:服务端加载时签名验证测试(TDD RED→GREEN)。
/// 验证 GameModuleLoader 在配置公钥时执行签名+md5 校验,未配置时跳过(向后兼容)。
/// </summary>
public class GameModuleSignatureTests : IDisposable
{
private readonly string _tempRoot;
private readonly string _gameDir; // {tempRoot}/rps/1/
public GameModuleSignatureTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), "sig_test_" + Guid.NewGuid().ToString("N"));
_gameDir = Path.Combine(_tempRoot, "rps", "1");
Directory.CreateDirectory(_gameDir);
// 从已构建的 TestGames/rps/1 复制 3 个文件
string src = Path.Combine(TestGames.Root, "rps", "1");
foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" })
File.Copy(Path.Combine(src, name), Path.Combine(_gameDir, name), overwrite: true);
}
public void Dispose()
{
if (Directory.Exists(_tempRoot))
{
// Windows 下 ALC 加载的 DLL 可能还被锁住;先做 GC 等待释放,再删除
for (int i = 0; i < 5; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
try { Directory.Delete(_tempRoot, true); }
catch (UnauthorizedAccessException) { /* Windows 文件锁:测试结束后 GC 会最终释放;不影响测试结果 */ }
}
}
// 生成签名 fixture 并写入 files.txt + files.txt.sig
private static (string pubPem, string privPem) GenerateKeyPair()
{
using var rsa = RSA.Create(2048);
return (rsa.ExportRSAPublicKeyPem(), rsa.ExportRSAPrivateKeyPem());
}
private void WriteSignature(string privPem)
{
var fm = new FilesManifest();
foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" })
{
string p = Path.Combine(_gameDir, name);
byte[] b = File.ReadAllBytes(p);
fm.Add(name, Md5Util.OfBytes(b), b.Length);
}
File.WriteAllText(Path.Combine(_gameDir, "files.txt"), fm.Render());
File.WriteAllBytes(Path.Combine(_gameDir, "files.txt.sig"),
ManifestSigner.Sign(fm.Digest(), privPem));
}
[Fact]
public void Load_WithValidSignature_Succeeds()
{
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
var module = new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1);
Assert.NotNull(module);
Assert.Equal("rps", module.GameId);
Assert.Equal(1, module.Version);
var room = module.CreateRoom();
Assert.NotNull(room);
// 卸载 ALC,释放 Windows 文件锁,以便 Dispose() 能清理临时目录
room = null;
module.Unload();
GC.Collect();
GC.WaitForPendingFinalizers();
}
[Fact]
public void Load_TamperedDll_Rejected()
{
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// 篡改 RPS.Core.dll 的一个字节,md5 随之改变
string coreDll = Path.Combine(_gameDir, "RPS.Core.dll");
byte[] bytes = File.ReadAllBytes(coreDll);
bytes[0] ^= 0xFF; // 翻转首字节
File.WriteAllBytes(coreDll, bytes);
var ex = Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
Assert.Contains("md5", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_WrongKey_Rejected()
{
var (_, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// 用另一组密钥的公钥验证 → 签名不符
var (wrongPubPem, _) = GenerateKeyPair();
var ex = Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(wrongPubPem).Load(_tempRoot, "rps", 1));
Assert.Contains("签名", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_MissingSignature_WhenKeyConfigured_Rejected()
{
var (pubPem, _) = GenerateKeyPair();
// 故意不写 files.txt.sig(签名文件缺失)
var ex = Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
Assert.Contains("签名", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_NoKeyConfigured_SkipsVerification()
{
// 不配置公钥,使用无签名的原始 TestGames/rps/1 目录
// 向后兼容:无公钥时跳过验证,正常加载
var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
Assert.NotNull(module);
Assert.Equal("rps", module.GameId);
var room = module.CreateRoom();
Assert.NotNull(room);
}
[Fact]
public void Load_TamperedGameJson_Rejected()
{
// 构建签名 fixture(与其他测试相同方式)
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// 篡改 game.json:追加一个空格,改变其 md5,使清单 md5 校验失败
File.AppendAllText(Path.Combine(_gameDir, "game.json"), " ");
Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
}
[Fact]
public void Load_TamperedManifestWithoutResign_Rejected()
{
// 构建签名 fixture
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// 攻击者篡改 RPS.Core.dll 一字节,并重算其 md5 写回 files.txt
// 但没有私钥无法重签 files.txt.sig → 验签时发现 Digest 与 sig 不符 → 拒绝
string coreDll = Path.Combine(_gameDir, "RPS.Core.dll");
byte[] bytes = File.ReadAllBytes(coreDll);
bytes[0] ^= 0xFF;
File.WriteAllBytes(coreDll, bytes);
// 重算被篡改 DLL 的 md5,重写 files.txtfiles.txt.sig 保持不变)
string newMd5 = Md5Util.OfFile(coreDll);
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(_gameDir, "files.txt")));
fm.Entries["RPS.Core.dll"].Md5 = newMd5;
File.WriteAllText(Path.Combine(_gameDir, "files.txt"), fm.Render());
Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
}
}
}