334 lines
14 KiB
C#
334 lines
14 KiB
C#
using WxAgent.Core;
|
|
using Xunit;
|
|
|
|
namespace WxAgent.Core.Tests;
|
|
|
|
public sealed class WcdbKeyCandidateScannerTests
|
|
{
|
|
[Fact]
|
|
public void ParsesChunkSplitsLengthsDuplicatesAndBoundaries()
|
|
{
|
|
var key = string.Concat(Enumerable.Repeat("01", 32));
|
|
var salt = string.Concat(Enumerable.Repeat("ab", 16));
|
|
var longRun = key + "cafe" + salt;
|
|
var bytes = System.Text.Encoding.ASCII.GetBytes("z" + new string('f', 63) + "!" + key + " " + key + salt + "-" + longRun + ".");
|
|
var scanner = new WcdbKeyCandidateScanner();
|
|
|
|
var found = new List<WcdbKeyCandidate>();
|
|
found.AddRange(scanner.Feed(bytes.AsSpan(0, 31)));
|
|
found.AddRange(scanner.Feed(bytes.AsSpan(31, 80)));
|
|
found.AddRange(scanner.Feed(bytes.AsSpan(111), finalBlock: true));
|
|
|
|
Assert.Equal(2, found.Count);
|
|
Assert.Contains(found, candidate => candidate.EncKey == key && candidate.SaltHint is null);
|
|
Assert.Single(found, candidate => candidate.EncKey == key && candidate.SaltHint == salt);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(3)]
|
|
[InlineData(127)]
|
|
public void ParsesUtf16AcrossOddChunkBoundaries(int chunkSize)
|
|
{
|
|
var key = new string('a', 64);
|
|
var salt = new string('b', 32);
|
|
var bytes = System.Text.Encoding.Unicode.GetBytes("x'" + key + salt + "' " + key + salt);
|
|
var scanner = new WcdbKeyCandidateScanner(utf16: true);
|
|
var found = new List<WcdbKeyCandidate>();
|
|
for (var offset = 0; offset < bytes.Length; offset += chunkSize)
|
|
found.AddRange(scanner.Feed(bytes.AsSpan(offset, Math.Min(chunkSize, bytes.Length - offset))));
|
|
found.AddRange(scanner.Feed([], finalBlock: true));
|
|
Assert.Equal(new WcdbKeyCandidate(key, salt), Assert.Single(found));
|
|
}
|
|
|
|
[Fact]
|
|
public void Utf16GapsDoNotJoinFragments()
|
|
{
|
|
var scanner = new WcdbKeyCandidateScanner(utf16: true);
|
|
Assert.Empty(scanner.Feed(System.Text.Encoding.Unicode.GetBytes(new string('a', 40))));
|
|
Assert.Empty(scanner.Feed([0x61], finalBlock: true));
|
|
Assert.Empty(scanner.Feed(System.Text.Encoding.Unicode.GetBytes(new string('a', 24)), finalBlock: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void BoundsLongRunsAndDoesNotJoinAcrossExplicitGaps()
|
|
{
|
|
var key = string.Concat(Enumerable.Repeat("01", 32));
|
|
var salt = string.Concat(Enumerable.Repeat("ab", 16));
|
|
var scanner = new WcdbKeyCandidateScanner();
|
|
var longRun = System.Text.Encoding.ASCII.GetBytes(key + new string('c', 1_000_000) + salt);
|
|
|
|
var found = new List<WcdbKeyCandidate>();
|
|
for (var offset = 0; offset < longRun.Length; offset += 4093)
|
|
{
|
|
found.AddRange(scanner.Feed(longRun.AsSpan(offset, Math.Min(4093, longRun.Length - offset))));
|
|
}
|
|
found.AddRange(scanner.Feed([], finalBlock: true));
|
|
|
|
var candidate = Assert.Single(found);
|
|
Assert.Equal(key, candidate.EncKey);
|
|
Assert.Equal(salt, candidate.SaltHint);
|
|
|
|
var gapScanner = new WcdbKeyCandidateScanner();
|
|
Assert.Empty(gapScanner.Feed(System.Text.Encoding.ASCII.GetBytes(key[..40])));
|
|
Assert.Empty(gapScanner.Feed([], finalBlock: true));
|
|
Assert.Empty(gapScanner.Feed(System.Text.Encoding.ASCII.GetBytes(key[40..]), finalBlock: true));
|
|
}
|
|
}
|
|
|
|
public sealed class SqlCipherPageVerifierTests
|
|
{
|
|
private const string ExpectedMac = "61b73efa9571819b9efd9a8424bd0e3882bb2c35baf5603262a05c630fab78a7c35ffc4041b589a09c9ed8df25457a131608bbfe67af87ebcba5268c8ddee055";
|
|
|
|
[Fact]
|
|
public void MatchesIndependentKnownAnswerAndRejectsMutations()
|
|
{
|
|
var key = Enumerable.Range(0, 32).Select(value => (byte)value).ToArray();
|
|
var page = CreateKnownAnswerPage();
|
|
|
|
Assert.True(SqlCipherPageVerifier.Verify(page, key));
|
|
Assert.True(SqlCipherPageVerifier.VerifyHexKey(page, Convert.ToHexString(key)));
|
|
|
|
AssertRejected(page, key, 0);
|
|
AssertRejected(page, key, 16);
|
|
AssertRejected(page, key, page.Length - 1);
|
|
|
|
var wrongKey = key.ToArray();
|
|
wrongKey[0] ^= 1;
|
|
Assert.False(SqlCipherPageVerifier.Verify(page, wrongKey));
|
|
Assert.False(SqlCipherPageVerifier.Verify(page, key, 2));
|
|
}
|
|
|
|
private static byte[] CreateKnownAnswerPage()
|
|
{
|
|
var page = new byte[SqlCipherPageVerifier.PageSize];
|
|
for (var index = 0; index < SqlCipherPageVerifier.SaltSize; index++)
|
|
{
|
|
page[index] = (byte)(index + 16);
|
|
}
|
|
|
|
for (var index = SqlCipherPageVerifier.SaltSize; index < page.Length - SqlCipherPageVerifier.HmacSize; index++)
|
|
{
|
|
page[index] = (byte)(index * 31 + 7);
|
|
}
|
|
|
|
Convert.FromHexString(ExpectedMac).CopyTo(page, page.Length - SqlCipherPageVerifier.HmacSize);
|
|
return page;
|
|
}
|
|
|
|
private static void AssertRejected(byte[] page, byte[] key, int index)
|
|
{
|
|
var changed = page.ToArray();
|
|
changed[index] ^= 1;
|
|
Assert.False(SqlCipherPageVerifier.Verify(changed, key));
|
|
}
|
|
}
|
|
|
|
public sealed class ChatMessageTests
|
|
{
|
|
[Fact]
|
|
public void ParsesDuplicateVisibleMessagesWithStableDistinctFingerprints()
|
|
{
|
|
var first = VisibleMessageParser.Parse(["same", "same", "other"]);
|
|
var second = VisibleMessageParser.Parse(["same", "same", "other"]);
|
|
|
|
Assert.Equal(3, first.Count);
|
|
Assert.Equal(3, first.Select(message => message.Fingerprint).Distinct().Count());
|
|
Assert.Equal(first.Select(message => message.Fingerprint), second.Select(message => message.Fingerprint));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifiesBasicVisibleMessageTypes()
|
|
{
|
|
Assert.Equal(ChatMessageType.Text, VisibleMessageParser.Classify("hello"));
|
|
Assert.Equal(ChatMessageType.Image, VisibleMessageParser.Classify("[图片]"));
|
|
Assert.Equal(ChatMessageType.File, VisibleMessageParser.Classify("report.pdf"));
|
|
Assert.Equal(ChatMessageType.Video, VisibleMessageParser.Classify("[视频]"));
|
|
Assert.Equal(ChatMessageType.Voice, VisibleMessageParser.Classify("语音\n3秒"));
|
|
Assert.Equal(ChatMessageType.Link, VisibleMessageParser.Classify("https://example.com"));
|
|
Assert.Equal(ChatMessageType.System, VisibleMessageParser.Classify("[系统消息]"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsesQuotedReplyAndKeepsFingerprintStableAcrossRuntimeIdChanges()
|
|
{
|
|
var parsed = VisibleMessageParser.Parse([new ChatMessageSource("引用:Alice\noriginal text\nreply text", "runtime-42")]);
|
|
var repeated = VisibleMessageParser.Parse([new ChatMessageSource("引用:Alice\noriginal text\nreply text", "runtime-99")]);
|
|
|
|
var message = Assert.Single(parsed);
|
|
Assert.Equal("reply text", message.Text);
|
|
Assert.Equal("Alice", message.Quote?.Sender);
|
|
Assert.Equal("original text", message.Quote?.Text);
|
|
Assert.Equal(message.Fingerprint, Assert.Single(repeated).Fingerprint);
|
|
|
|
var actualWechat = Assert.Single(VisibleMessageParser.Parse([
|
|
new ChatMessageSource("reply text\n引用 Rogee 的消息 : 图片", "runtime-43")
|
|
]));
|
|
Assert.Equal("reply text", actualWechat.Text);
|
|
Assert.Equal("Rogee", actualWechat.Quote?.Sender);
|
|
Assert.Equal("图片", actualWechat.Quote?.Text);
|
|
Assert.Equal(ChatMessageType.Quote, actualWechat.Type);
|
|
|
|
var duplicates = VisibleMessageParser.Parse([new ChatMessageSource("same"), new ChatMessageSource("same")]);
|
|
Assert.NotEqual(duplicates[0].Fingerprint, duplicates[1].Fingerprint);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeduperIsBounded()
|
|
{
|
|
var deduper = new BoundedMessageDeduper(2);
|
|
Assert.True(deduper.TryAdd("a"));
|
|
Assert.False(deduper.TryAdd("a"));
|
|
Assert.True(deduper.TryAdd("b"));
|
|
Assert.True(deduper.TryAdd("c"));
|
|
Assert.True(deduper.TryAdd("a"));
|
|
}
|
|
}
|
|
|
|
public sealed class MessageEventTests
|
|
{
|
|
[Fact]
|
|
public void CheckpointSuppressesDuplicatesAndRecoversUnseenMessages()
|
|
{
|
|
var oldMessage = Assert.Single(VisibleMessageParser.Parse([new ChatMessageSource("old", "runtime-old")]));
|
|
var newMessage = Assert.Single(VisibleMessageParser.Parse([new ChatMessageSource("new", "runtime-new")]));
|
|
var initial = new MessageEventState(4);
|
|
Assert.NotNull(initial.TryCreateMessage("文件传输助手", oldMessage, DateTimeOffset.UnixEpoch, false));
|
|
var checkpoint = initial.CreateCheckpoint("文件传输助手", DateTimeOffset.UnixEpoch);
|
|
Assert.Equal(2, checkpoint.Version);
|
|
|
|
var resumed = new MessageEventState(4, checkpoint);
|
|
Assert.Null(resumed.TryCreateMessage("文件传输助手", oldMessage, DateTimeOffset.UnixEpoch, true));
|
|
var recovered = resumed.TryCreateMessage("文件传输助手", newMessage, DateTimeOffset.UnixEpoch, true);
|
|
Assert.NotNull(recovered);
|
|
Assert.True(recovered!.Recovered);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallbackFailureDoesNotStopLaterCallbacks()
|
|
{
|
|
var called = false;
|
|
var messageEvent = new MessageEvent("id", MessageEventKind.Reconnected, "文件传输助手", null, DateTimeOffset.UnixEpoch, true);
|
|
var failures = await MessageCallbackDispatcher.DispatchAsync(
|
|
[
|
|
(_, _) => throw new InvalidOperationException("boom"),
|
|
(_, _) => { called = true; return Task.CompletedTask; }
|
|
],
|
|
messageEvent,
|
|
CancellationToken.None);
|
|
|
|
Assert.True(called);
|
|
Assert.Single(failures);
|
|
Assert.Equal(0, failures[0].CallbackIndex);
|
|
}
|
|
}
|
|
|
|
public sealed class SessionTests
|
|
{
|
|
[Fact]
|
|
public void ParsesVisibleSessionsAndMarksCurrent()
|
|
{
|
|
var sessions = WechatSessionParser.Parse(
|
|
[("session_item_文件传输助手", "文件传输助手"), ("noise", "ignored"), ("session_item_文件传输助手", "duplicate")],
|
|
"文件传输助手");
|
|
|
|
var session = Assert.Single(sessions);
|
|
Assert.True(session.IsCurrent);
|
|
Assert.Equal("文件传输助手", session.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void StripsPreviewMetadataFromVisibleSessionNames()
|
|
{
|
|
var sessions = WechatSessionParser.Parse(
|
|
[("session_item_文件传输助手", "文件传输助手\n已置顶\n最后一条消息\n22:25")],
|
|
"文件传输助手");
|
|
|
|
var session = Assert.Single(sessions);
|
|
Assert.Equal("文件传输助手", session.Name);
|
|
Assert.True(session.IsCurrent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsesSearchResultsAndMarksOnlyExactLines()
|
|
{
|
|
var results = WechatSessionParser.ParseSearchResults(
|
|
[("search_item_function_文件传输助手", "功能\n文件传输助手"), ("search_item_friend_1", "联系人\n文件传输助手测试")],
|
|
"文件传输助手");
|
|
|
|
Assert.Equal(2, results.Count);
|
|
var exact = Assert.Single(results, result => result.IsExactMatch);
|
|
Assert.Equal("文件传输助手", exact.Name);
|
|
Assert.Equal("功能", exact.Category);
|
|
}
|
|
}
|
|
|
|
public sealed class IdentityAndSanitizationTests
|
|
{
|
|
[Fact]
|
|
public void AccountFingerprintUsesStableWindowsPathNormalization()
|
|
{
|
|
Assert.Equal(
|
|
AccountRootFingerprint.Create(@"c:/Users/Rogee/Documents/xwechat_files/account/db_storage/"),
|
|
AccountRootFingerprint.Create(@"C:\Users\Rogee\Documents\xwechat_files\account\db_storage"));
|
|
Assert.NotEqual(AccountRootFingerprint.Create(@"C:\one"), AccountRootFingerprint.Create(@"C:\two"));
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotSanitizerKeepsStructureAndRedactsContent()
|
|
{
|
|
Assert.Equal("session_list", UiSnapshotSanitizer.SanitizeAutomationId("session_list"));
|
|
Assert.NotEqual("发送", UiSnapshotSanitizer.SanitizeName("发送"));
|
|
Assert.StartsWith("[text:2:", UiSnapshotSanitizer.SanitizeName("发送"));
|
|
Assert.DoesNotContain("Alice", UiSnapshotSanitizer.SanitizeAutomationId("session_item_Alice"));
|
|
Assert.DoesNotContain("secret message", UiSnapshotSanitizer.SanitizeName("secret message"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifiesLoggedOutAndChangedUiWithoutGuessingActiveAccount()
|
|
{
|
|
var controls = WechatLocators.RequiredAutomationIds.ToDictionary(id => id, _ => false);
|
|
Assert.Equal(WxAgentErrorCode.WechatNotLoggedIn, WechatLocators.ClassifyFoundWindow(controls));
|
|
|
|
controls[WechatLocators.MainView] = true;
|
|
Assert.Equal(WxAgentErrorCode.UiStructureChanged, WechatLocators.ClassifyFoundWindow(controls));
|
|
|
|
controls[WechatLocators.SessionList] = true;
|
|
Assert.Null(WechatLocators.ClassifyFoundWindow(controls));
|
|
|
|
controls[WechatLocators.ChatPage] = true;
|
|
Assert.Equal(WxAgentErrorCode.UiStructureChanged, WechatLocators.ClassifyFoundWindow(controls));
|
|
|
|
foreach (var id in controls.Keys.ToArray())
|
|
{
|
|
controls[id] = true;
|
|
}
|
|
Assert.Null(WechatLocators.ClassifyFoundWindow(controls));
|
|
}
|
|
|
|
[Fact]
|
|
public void KeyStoreMergePreservesHistoricalAccountsAndDatabases()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var oldDatabase = new DatabaseKeyEvidence("message/msg_0.db", "00", new string('1', 64), 1, now.AddDays(-1), KeyBindingConfidence.PageHmacVerified);
|
|
var newDatabase = new DatabaseKeyEvidence("contact/contact.db", "11", new string('2', 64), 2, now, KeyBindingConfidence.PageHmacVerified);
|
|
var existing = new[]
|
|
{
|
|
new AccountKeySet("account-a", @"C:\\a", "4.1.0", now.AddDays(-1), [oldDatabase]),
|
|
new AccountKeySet("account-history", @"C:\\history", "4.0.0", now.AddDays(-2), [])
|
|
};
|
|
var incoming = new[]
|
|
{
|
|
new AccountKeySet("account-a", @"C:\\a", "4.1.8", now, [newDatabase])
|
|
};
|
|
|
|
var merged = AccountKeySetMerge.Merge(existing, incoming);
|
|
|
|
Assert.Equal(2, merged.Count);
|
|
var account = Assert.Single(merged, item => item.AccountRootFingerprint == "account-a");
|
|
Assert.Equal("4.1.8", account.WechatVersion);
|
|
Assert.Equal(2, account.Databases.Count);
|
|
Assert.Contains(merged, item => item.AccountRootFingerprint == "account-history");
|
|
}
|
|
}
|