diff --git a/docs/validation/Database-Merged-Chat-2026-09-06.md b/docs/validation/Database-Merged-Chat-2026-09-06.md new file mode 100644 index 0000000..6d97d6a --- /dev/null +++ b/docs/validation/Database-Merged-Chat-2026-09-06.md @@ -0,0 +1,41 @@ +# 数据库合并聊天记录展开 — 2026-09-06 + +## 调用方式 + +```text +WxAgent.Host db merged --account --chat filehelper --local-id 149 +WxAgent.Host db merged --account --chat filehelper --local-id 149 --include-content +``` + +可选 `--database ` 消除不同消息分库 local ID 重复时的歧义;支持 `--key-file` 与 `--timeout`。只使用既有账户密钥缓存,不调用内存扫描器,也不打开聊天窗口。 + +默认返回父记录身份、条目路径、类型、脱敏发送人标识、原消息 ID、时间戳和正文长度;正文、显示名称、合并标题和描述为 null。`--include-content` 显式启用正文/名称。源 `hashusername` 不是可确认的真实 wxid,因此不把它伪装为 wxid。 + +## 实现与安全 + +- 将数据库 `local_type` 模型和转换从 Int32 修正为 **Int64**;实测合并类型为 `81604378673`。原先直接读取此记录会发生 Int32 溢出。 +- 精确按聊天表及 `local_id` 参数查询,逐个消息分库检查;多处命中必须显式选择分库,不返回第一个碰巧匹配的消息。 +- 解析 `appmsg/type=19 → recorditem → recordinfo/datalist/dataitem`,保留顺序、重复正文、原始字符串 ID、秒级 Unix 时间、显示时间和发送人名称。嵌套记录拥有独立路径,不把重复 dataid 当作去重依据。 +- XML 禁止 DTD/外部实体,限制文档字符数、深度、嵌套记录层数及总条目数。声明数量与实际不符、文本缺失、非法时间等明确报错,不用空结果掩盖损坏。 +- Hex/Zstd 解码有尺寸上限,避免压缩消息无限膨胀。 +- 每次 SQLCipher 打开前,缓存密钥重新通过**当前目标数据库 page-1 HMAC**校验;连接仍为 `Mode=ReadOnly` 与 `query_only=ON`。 +- 移除原来的 `CREATE TABLE` 写拒绝探针,改为读取 `sqlite3_db_readonly`,不尝试任何数据库写入。打开失败时也释放连接。 +- 仅输出允许的附件元数据,不导出 CDN 地址/AES 密钥,不下载附件或绕过协议。 + +## 真机验收 + +- 微信 `4.1.13.63`,Windows `10.1.1.101`。 +- 最终发布:`C:\Users\Rogee\wx-agent\releases\db-merged-20260906T120104`。 +- EXE SHA-256:`5DB6A6422F6DAD9AE76C6C4D5A522576C7B7EB0534396434A536190D4E0DEEF8`。 +- Linux **129/129 Core 测试通过**,完整 Release build 无警告/错误。 +- `Test-DatabaseMerged.ps1` 在 **SSH Session 0** 运行,5/5 通过:默认脱敏输出、显式正文输出、数据库只读状态、非法 ID、缺失 ID。数据库读操作无需微信所在交互桌面;没有在 Session 0 执行 UI 自动化。 +- 既有交互会话另行执行 doctor、inspect-ui、`smoke --read-only`,3/3 通过。 +- 文件传输助手中父记录 local ID **149** 展开 **16 条文本消息**。 +- 用独立 Python XML 解析器逐条对照数据库原始嵌入 XML:正文、发送人、原始消息 ID、时间戳 **各 16/16 完全一致**。 +- 全程发送/转发次数 **0**。完整正文仅保留于忽略的私有验证产物,不进入 Git。 + +脱敏证据:[`evidence/Database-Merged-20260906/`](evidence/Database-Merged-20260906/)。第一次验证脚本误读 `metadata.WritesRejected` 的嵌套层次,修正脚本后同一二进制通过;并非数据库接受了写入。 + +## 验收范围 + +真实样本为 16 条文本;嵌套记录、附件元数据、恶意/截断 XML、重复条目和解压上限有离线测试。尚无混合图片/视频/文件、嵌套合并的真机样本;附件正文/原文件下载不属于本次展开结果。字段缺失保留 null,不补造身份或时间。 diff --git a/docs/validation/evidence/Database-Merged-20260906/baseline-summary.json b/docs/validation/evidence/Database-Merged-20260906/baseline-summary.json new file mode 100644 index 0000000..85bacd3 --- /dev/null +++ b/docs/validation/evidence/Database-Merged-20260906/baseline-summary.json @@ -0,0 +1,18 @@ +{ + "Checks": [ + { + "Stage": "doctor", + "ExitCode": 0 + }, + { + "Stage": "inspect-ui", + "ExitCode": 0 + }, + { + "Stage": "smoke-readonly", + "ExitCode": 0 + } + ], + "SendsAttempted": 0, + "Success": true +} diff --git a/docs/validation/evidence/Database-Merged-20260906/invalid-id.json b/docs/validation/evidence/Database-Merged-20260906/invalid-id.json new file mode 100644 index 0000000..b03f722 --- /dev/null +++ b/docs/validation/evidence/Database-Merged-20260906/invalid-id.json @@ -0,0 +1,4 @@ +{ + "error": "InvalidArgument", + "Message": "--local-id must be a positive 64-bit integer." +} diff --git a/docs/validation/evidence/Database-Merged-20260906/merged-default.json b/docs/validation/evidence/Database-Merged-20260906/merged-default.json new file mode 100644 index 0000000..2f8c720 --- /dev/null +++ b/docs/validation/evidence/Database-Merged-20260906/merged-default.json @@ -0,0 +1,307 @@ +{ + "account": "a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277", + "database": "message/message_0.db", + "chatId": "sha256:7F7D698D0AE00B0D", + "recordId": "a2e8a1eaab7fd5fbd3806525c0d9cce750c28f4efb8e7cfcabc9e3e8660e0277:message/message_0.db:149", + "parent": { + "LocalId": 149, + "ServerId": 7498992953238207040, + "Type": 81604378673, + "Timestamp": "2026-09-06T09:18:20+00:00" + }, + "record": { + "title": null, + "description": null, + "count": 16, + "messages": [ + { + "path": "0", + "dataId": "d7a3cf5d308ef865ededc7cda76cdf06", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "5158611296847210182", + "timestamp": "2026-08-28T08:13:35+00:00", + "displayTime": "2026-08-28 16:13", + "text": null, + "length": 14, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "1", + "dataId": "a55bf6920207cc484914173ca7ec98ff", + "dataType": 1, + "senderId": "sha256:2F0AF7C1B8E3D684", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "2505042190386030576", + "timestamp": "2026-08-28T08:14:27+00:00", + "displayTime": "2026-08-28 16:14", + "text": null, + "length": 1, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "2", + "dataId": "30831307ce3e4909af357ca3a0d36492", + "dataType": 1, + "senderId": "sha256:2F0AF7C1B8E3D684", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "4314853433147875167", + "timestamp": "2026-08-28T08:14:31+00:00", + "displayTime": "2026-08-28 16:14", + "text": null, + "length": 3, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "3", + "dataId": "835abcae449f3e156d2e4961c42138ff", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "6850076042613883819", + "timestamp": "2026-08-28T08:15:35+00:00", + "displayTime": "2026-08-28 16:15", + "text": null, + "length": 41, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "4", + "dataId": "fd7fcef26f3408b3e24fcf526f8c101b", + "dataType": 1, + "senderId": "sha256:2F0AF7C1B8E3D684", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "6621524893657137135", + "timestamp": "2026-08-28T08:16:12+00:00", + "displayTime": "2026-08-28 16:16", + "text": null, + "length": 3, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "5", + "dataId": "6fe59e85d75f439e66c9871458e322c1", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "6108206286854864988", + "timestamp": "2026-08-28T08:16:59+00:00", + "displayTime": "2026-08-28 16:16", + "text": null, + "length": 58, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "6", + "dataId": "b0195dfb34d6552e9697717c85e3244d", + "dataType": 1, + "senderId": "sha256:2F0AF7C1B8E3D684", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "6291818813835930172", + "timestamp": "2026-08-28T08:17:32+00:00", + "displayTime": "2026-08-28 16:17", + "text": null, + "length": 9, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "7", + "dataId": "7865e3c4ef4610909cbda0034897852b", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "8805680531905882697", + "timestamp": "2026-08-28T08:17:57+00:00", + "displayTime": "2026-08-28 16:17", + "text": null, + "length": 19, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "8", + "dataId": "ec62b6595e5f3391381b82b847d47de3", + "dataType": 1, + "senderId": "sha256:2F0AF7C1B8E3D684", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "7935299570123740393", + "timestamp": "2026-08-28T08:18:07+00:00", + "displayTime": "2026-08-28 16:18", + "text": null, + "length": 3, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "9", + "dataId": "8871d9bfe9c261850b155f737b9e0fc4", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "5334061395816435285", + "timestamp": "2026-08-28T08:18:11+00:00", + "displayTime": "2026-08-28 16:18", + "text": null, + "length": 8, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "10", + "dataId": "06987bf3be56f93fe26f516ee9fce712", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "3775467287450770804", + "timestamp": "2026-08-28T08:18:21+00:00", + "displayTime": "2026-08-28 16:18", + "text": null, + "length": 5, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "11", + "dataId": "811bbaf41661bc93de162cb79051d30c", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "5606979100728674670", + "timestamp": "2026-08-28T08:18:33+00:00", + "displayTime": "2026-08-28 16:18", + "text": null, + "length": 4, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "12", + "dataId": "1b54a0acfa9af3f87612f1ad1d899615", + "dataType": 1, + "senderId": "sha256:2F0AF7C1B8E3D684", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "8768702616063460855", + "timestamp": "2026-08-28T08:18:34+00:00", + "displayTime": "2026-08-28 16:18", + "text": null, + "length": 1, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "13", + "dataId": "8f995c7506723cb9bbac6428de6e48da", + "dataType": 1, + "senderId": "sha256:2F0AF7C1B8E3D684", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "540359944811197130", + "timestamp": "2026-08-28T08:18:39+00:00", + "displayTime": "2026-08-28 16:18", + "text": null, + "length": 2, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "14", + "dataId": "6444ca65742edd89728ec3872ad10bef", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "3039346887632908543", + "timestamp": "2026-08-28T08:19:05+00:00", + "displayTime": "2026-08-28 16:19", + "text": null, + "length": 9, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + }, + { + "path": "15", + "dataId": "2489f0db398df842a99962035db3f82e", + "dataType": 1, + "senderId": "sha256:6221F48E5D88469F", + "senderName": null, + "sourceLocalId": null, + "sourceServerId": "679561649162720019", + "timestamp": "2026-08-28T08:19:16+00:00", + "displayTime": "2026-08-28 16:19", + "text": null, + "length": 8, + "contentAvailable": true, + "title": null, + "format": null, + "sizeBytes": null, + "nestedRecord": null + } + ] + } +} diff --git a/docs/validation/evidence/Database-Merged-20260906/missing-id.json b/docs/validation/evidence/Database-Merged-20260906/missing-id.json new file mode 100644 index 0000000..bce40db --- /dev/null +++ b/docs/validation/evidence/Database-Merged-20260906/missing-id.json @@ -0,0 +1,4 @@ +{ + "error": "ControlNotFound", + "Message": "The selected database message was not found." +} diff --git a/docs/validation/evidence/Database-Merged-20260906/readonly.json b/docs/validation/evidence/Database-Merged-20260906/readonly.json new file mode 100644 index 0000000..fe6f2e6 --- /dev/null +++ b/docs/validation/evidence/Database-Merged-20260906/readonly.json @@ -0,0 +1,3 @@ +{ + "WritesRejected": true +} diff --git a/docs/validation/evidence/Database-Merged-20260906/summary.json b/docs/validation/evidence/Database-Merged-20260906/summary.json new file mode 100644 index 0000000..97c9bfe --- /dev/null +++ b/docs/validation/evidence/Database-Merged-20260906/summary.json @@ -0,0 +1,34 @@ +{ + "BinarySha256": "5DB6A6422F6DAD9AE76C6C4D5A522576C7B7EB0534396434A536190D4E0DEEF8", + "SendsAttempted": 0, + "Checks": [ + { + "ExpectedExit": 0, + "ExitCode": 0, + "Stage": "merged-default" + }, + { + "ExpectedExit": 0, + "ExitCode": 0, + "Stage": "merged-content.private" + }, + { + "ExpectedExit": 0, + "ExitCode": 0, + "Stage": "database-readonly" + }, + { + "ExpectedExit": 1, + "ExitCode": 1, + "Stage": "invalid-id" + }, + { + "ExpectedExit": 1, + "ExitCode": 1, + "Stage": "missing-id" + } + ], + "Success": true, + "ProcessSessionId": 0, + "RecordCount": 16 +} diff --git a/docs/validation/evidence/Database-Merged-20260906/xml-cross-check.json b/docs/validation/evidence/Database-Merged-20260906/xml-cross-check.json new file mode 100644 index 0000000..94c59b9 --- /dev/null +++ b/docs/validation/evidence/Database-Merged-20260906/xml-cross-check.json @@ -0,0 +1,9 @@ +{ + "MessageCount": 16, + "ExactTextMatches": 16, + "ExactSenderMatches": 16, + "ExactSourceIdMatches": 16, + "ExactTimestampMatches": 16, + "IndependentParser": "Python xml.etree.ElementTree", + "ContentIncluded": false +} diff --git a/scripts/windows/Test-DatabaseMerged.ps1 b/scripts/windows/Test-DatabaseMerged.ps1 new file mode 100644 index 0000000..050696d --- /dev/null +++ b/scripts/windows/Test-DatabaseMerged.ps1 @@ -0,0 +1,43 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputDirectory, + [Parameter(Mandatory=$true)][string]$Account, + [string]$ChatId='filehelper', + [Parameter(Mandatory=$true)][long]$LocalId, + [Parameter(Mandatory=$true)][int]$ExpectedCount +) +$ErrorActionPreference='Stop' +$exe=(Resolve-Path $Executable).Path +if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'} +$out=(New-Item -ItemType Directory $OutputDirectory).FullName +$checks=New-Object System.Collections.Generic.List[object] +function Check([string]$Name,[string[]]$Arguments,[int]$ExpectedExit=0){ + $raw=(& $exe @Arguments 2> "$out\$Name.stderr.txt" | Out-String) + $code=$LASTEXITCODE + $raw | Set-Content "$out\$Name.json" -Encoding UTF8 + $checks.Add(@{Stage=$Name;ExitCode=$code;ExpectedExit=$ExpectedExit}) + if($code -ne $ExpectedExit){throw "$Name returned unexpected exit code."} + return ($raw | ConvertFrom-Json) +} +try { + $default=Check 'merged-default' @('db','merged','--account',$Account,'--chat',$ChatId,'--local-id',"$LocalId",'--timeout','60') + if($default.record.count -ne $ExpectedCount){throw 'Unexpected record count.'} + if($null -ne $default.record.title -or $null -ne $default.record.description -or @($default.record.messages | Where-Object {$null -ne $_.text -or $null -ne $_.senderName}).Count){throw 'Default output leaked record content.'} + $full=Check 'merged-content.private' @('db','merged','--account',$Account,'--chat',$ChatId,'--local-id',"$LocalId",'--include-content','--timeout','60') + if($full.record.count -ne $ExpectedCount -or $full.parent.LocalId -ne $LocalId){throw 'Selected record mismatch.'} + if(@($full.record.messages | Where-Object {$_.dataType -ne 1 -or $null -eq $_.text -or !$_.senderName -or !$_.timestamp -or !$_.sourceServerId}).Count){throw 'Expected complete text-message metadata in this fixture.'} + if(@($full.record.messages.path | Select-Object -Unique).Count -ne $ExpectedCount){throw 'Message paths are not unique.'} + $metadata=Check 'database-readonly' @('db','query','--account',$Account,'--database',$full.database,'--timeout','60') + if(!$metadata.metadata.WritesRejected){throw 'Database is not reported read-only.'} + $invalid=Check 'invalid-id' @('db','merged','--account',$Account,'--chat',$ChatId,'--local-id','0','--timeout','30') 1 + if($invalid.error -ne 'InvalidArgument'){throw 'Wrong invalid-ID error.'} + $missing=Check 'missing-id' @('db','merged','--account',$Account,'--chat',$ChatId,'--local-id','9223372036854775807','--timeout','60') 1 + if($missing.error -ne 'ControlNotFound'){throw 'Wrong missing-ID error.'} +} catch { + @{Message=$_.Exception.Message;Line=$_.InvocationInfo.ScriptLineNumber} | ConvertTo-Json | Set-Content "$out\error.json" -Encoding UTF8 +} finally { + $success=($checks.Count -eq 5 -and @($checks | Where-Object {$_.ExitCode -ne $_.ExpectedExit}).Count -eq 0 -and !(Test-Path "$out\error.json")) + @{Success=$success;Checks=@($checks.ToArray());ProcessSessionId=[Diagnostics.Process]::GetCurrentProcess().SessionId;RecordCount=$ExpectedCount;SendsAttempted=0;BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash} | + ConvertTo-Json -Depth 6 | Set-Content "$out\summary.json" -Encoding UTF8 +} +if(!$success){exit 1} diff --git a/src/WxAgent.Core/WechatDbMessage.cs b/src/WxAgent.Core/WechatDbMessage.cs index 33bdf61..56f66cf 100644 --- a/src/WxAgent.Core/WechatDbMessage.cs +++ b/src/WxAgent.Core/WechatDbMessage.cs @@ -12,7 +12,7 @@ public sealed record DbMessage( string? SenderWxId, string? SenderName, string? SenderAvatarUrl, - int Type, + long Type, string Content, DateTimeOffset Timestamp, bool? IsSelf); @@ -26,6 +26,8 @@ public static class WechatDbMessage public static string DecodeContent(string hexContent, bool compressed) { if (string.IsNullOrEmpty(hexContent)) return string.Empty; + if (hexContent.Length > WechatMergedChatParser.MaxXmlCharacters * 2) + throw new InvalidDataException("Database message content exceeds the decoding limit."); byte[] bytes; try { @@ -43,9 +45,18 @@ public static class WechatDbMessage using var source = new MemoryStream(bytes); using var decompressor = new DecompressionStream(source); using var reader = new StreamReader(decompressor, Encoding.UTF8); - return reader.ReadToEnd(); + var decoded = new StringBuilder(); + var buffer = new char[4096]; + int read; + while ((read = reader.Read(buffer, 0, buffer.Length)) != 0) + { + if (decoded.Length + read > WechatMergedChatParser.MaxXmlCharacters) + throw new InvalidDataException("Decompressed message exceeds the decoding limit."); + decoded.Append(buffer, 0, read); + } + return decoded.ToString(); } - catch (Exception exception) when (exception is not OutOfMemoryException) + catch (Exception exception) when (exception is not (OutOfMemoryException or InvalidDataException)) { return string.Empty; } diff --git a/src/WxAgent.Core/WechatMergedChat.cs b/src/WxAgent.Core/WechatMergedChat.cs new file mode 100644 index 0000000..0a5e8f8 --- /dev/null +++ b/src/WxAgent.Core/WechatMergedChat.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using System.Xml; +using System.Xml.Linq; + +namespace WxAgent.Core; + +public sealed record WechatMergedMessage( + string Path, string? DataId, int DataType, string? SenderName, string? SenderHash, + string? SourceLocalId, string? SourceServerId, DateTimeOffset? Timestamp, string? DisplayTime, + string? Text, string? Title, string? Format, long? SizeBytes, WechatMergedChat? NestedRecord); + +public sealed record WechatMergedChat(string? Title, string? Description, IReadOnlyList Messages); + +public sealed record DbMergedChat(string DatabaseRelativePath, DbMessage Parent, WechatMergedChat Record); + +public static class WechatMergedChatParser +{ + public const int MaxXmlCharacters = 4 * 1024 * 1024; + public const int MaxMessages = 5000; + public const int MaxNesting = 8; + + public static WechatMergedChat Parse(string xml) + { + var budget = MaxXmlCharacters * 2; + var remaining = MaxMessages; + try + { + var root = Load(xml, ref budget); + if (root.Name == "msg") + { + var app = root.Element("appmsg") ?? throw Invalid("Missing appmsg."); + if (app.Element("type")?.Value != "19") throw Invalid("Not a merged-chat app message."); + root = ReadRecord(app.Element("recorditem") ?? throw Invalid("Missing recorditem."), ref budget); + } + return ParseRecord(root, "", 0, ref remaining, ref budget); + } + catch (XmlException ex) { throw new InvalidDataException("Invalid merged-chat XML.", ex); } + catch (ArgumentOutOfRangeException ex) { throw new InvalidDataException("Invalid merged-chat timestamp.", ex); } + } + + private static WechatMergedChat ParseRecord(XElement record, string parentPath, int depth, ref int remaining, ref int budget) + { + if (depth >= MaxNesting) throw Invalid("Nested record limit exceeded."); + if (record.Name != "recordinfo") throw Invalid("Expected recordinfo."); + var list = record.Element("datalist") ?? throw Invalid("Missing datalist."); + var items = list.Elements().ToArray(); + if (items.Any(item => item.Name != "dataitem")) throw Invalid("Unknown datalist element."); + if (items.Length > remaining) throw Invalid("Merged-message count limit exceeded."); + remaining -= items.Length; + var count = (string?)list.Attribute("count"); + if (count is not null && (!int.TryParse(count, NumberStyles.None, CultureInfo.InvariantCulture, out var expected) || expected != items.Length)) + throw Invalid("Declared merged-message count does not match the embedded record."); + var messages = new List(items.Length); + for (var index = 0; index < items.Length; index++) + { + var item = items[index]; + if (!int.TryParse((string?)item.Attribute("datatype"), NumberStyles.None, CultureInfo.InvariantCulture, out var type)) + throw Invalid("Missing or invalid merged-message datatype."); + var path = parentPath.Length == 0 ? index.ToString(CultureInfo.InvariantCulture) : $"{parentPath}/{index}"; + var text = Value(item, "datadesc"); + if (type == 1 && text is null) throw Invalid("Text record has no datadesc."); + var seconds = Integer(item, "srcMsgCreateTime"); + var source = item.Element("dataitemsource"); + var nestedElement = item.Element("recordinfo") ?? item.Element("recorditem"); + var nested = nestedElement is null ? null : ParseRecord(ReadRecord(nestedElement, ref budget), path, depth + 1, ref remaining, ref budget); + messages.Add(new WechatMergedMessage(path, (string?)item.Attribute("dataid"), type, + Value(item, "sourcename"), source is null ? null : Value(source, "hashusername"), + Value(item, "srcMsgLocalId"), Value(item, "fromnewmsgid"), + seconds is null ? null : DateTimeOffset.FromUnixTimeSeconds(seconds.Value), Value(item, "sourcetime"), + text, Value(item, "datatitle"), Value(item, "datafmt"), Integer(item, "datasize"), nested)); + } + return new WechatMergedChat(Value(record, "title"), Value(record, "desc"), messages); + } + + private static XElement ReadRecord(XElement element, ref int budget) + { + if (element.Name == "recordinfo" && element.HasElements) return element; + if (element.HasElements) + { + var children = element.Elements().ToArray(); + if (children.Length != 1 || children[0].Name != "recordinfo") throw Invalid("Ambiguous embedded record."); + return children[0]; + } + return Load(element.Value, ref budget); + } + + private static XElement Load(string xml, ref int budget) + { + if (string.IsNullOrWhiteSpace(xml) || xml.Length > MaxXmlCharacters || xml.Length > budget) + throw Invalid("Merged-chat XML is empty or exceeds the size limit."); + budget -= xml.Length; + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, + MaxCharactersInDocument = MaxXmlCharacters, MaxCharactersFromEntities = 0 + }; + // Preflight depth before building a DOM; untrusted forwarded XML must stay bounded. + using (var check = XmlReader.Create(new StringReader(xml), settings)) + while (check.Read()) + if (check.Depth > 64) throw Invalid("XML depth limit exceeded."); + using var reader = XmlReader.Create(new StringReader(xml), settings); + return XElement.Load(reader, LoadOptions.PreserveWhitespace); + } + + private static string? Value(XElement element, string name) => element.Element(name)?.Value; + + private static long? Integer(XElement element, string name) + { + var text = Value(element, name); + if (string.IsNullOrEmpty(text)) return null; + if (!long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var number)) + throw Invalid($"Invalid {name}."); + return number; + } + + private static InvalidDataException Invalid(string message) => new(message); +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index acedaaa..3ba4465 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -545,6 +545,30 @@ try }); return 0; } + case "db" when args[1] == "merged": + { + var accountId = GetRequiredOption(args, "--account"); + var chatId = GetRequiredOption(args, "--chat"); + if (!long.TryParse(GetRequiredOption(args, "--local-id"), System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var localId) || localId <= 0) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "--local-id must be a positive 64-bit integer."); + var accounts = await DatabaseKeyStore.LoadAsync(GetOption(args, "--key-file"), cancellationToken); + var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)) + ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Account fingerprint was not found in the key store."); + var result = await WechatMessageDbReader.ReadMergedAsync(account.AccountRootPath, account.Databases, chatId, localId, + cancellationToken, GetOption(args, "--database")); + var includeContent = HasOption(args, "--include-content"); + WriteJson(new + { + account = account.AccountRootFingerprint, + database = result.DatabaseRelativePath, + chatId = includeContent ? chatId : MaskIdentifier(chatId), + recordId = $"{account.AccountRootFingerprint}:{result.DatabaseRelativePath}:{localId}", + parent = new { result.Parent.LocalId, result.Parent.ServerId, result.Parent.Type, result.Parent.Timestamp }, + record = ToMergedOutput(result.Record, includeContent) + }); + return 0; + } case "db" when args[1] == "messages": { var accountId = GetRequiredOption(args, "--account"); @@ -793,6 +817,9 @@ static int ValidateCommandLine(string[] values) case "query": ValidateOptions(values, 2, ["--account", "--database", "--key-file", "--timeout"], []); return 30; + case "merged": + ValidateOptions(values, 2, ["--account", "--chat", "--local-id", "--database", "--key-file", "--timeout"], ["--include-content"]); + return 60; case "messages": ValidateOptions(values, 2, ["--account", "--chat", "--key-file", "--limit", "--timeout"], ["--include-content"]); return 60; @@ -846,6 +873,32 @@ static string? GetOption(string[] values, string name) static string GetRequiredOption(string[] values, string name) => GetOption(values, name) ?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing required option {name}."); +static object ToMergedOutput(WechatMergedChat record, bool includeContent) => new +{ + title = includeContent ? record.Title : null, + description = includeContent ? record.Description : null, + count = record.Messages.Count, + messages = record.Messages.Select(message => new + { + path = message.Path, + dataId = message.DataId, + dataType = message.DataType, + senderId = MaskIdentifier(message.SenderHash ?? message.SenderName), + senderName = includeContent ? message.SenderName : null, + sourceLocalId = message.SourceLocalId, + sourceServerId = message.SourceServerId, + timestamp = message.Timestamp, + displayTime = message.DisplayTime, + text = includeContent ? message.Text : null, + length = message.Text?.Length, + contentAvailable = message.Text is not null, + title = includeContent ? message.Title : null, + format = message.Format, + sizeBytes = message.SizeBytes, + nestedRecord = message.NestedRecord is null ? null : ToMergedOutput(message.NestedRecord, includeContent) + }) +}; + static string? MaskIdentifier(string? identifier) => string.IsNullOrEmpty(identifier) ? identifier @@ -950,6 +1003,7 @@ WxAgent.Host commands: db status [--key-file ] [--timeout 30] db query --account --database [--key-file ] [--timeout 30] db messages --account --chat [--limit 50] [--include-content] [--key-file ] [--timeout 60] + db merged --account --chat --local-id [--database ] [--include-content] [--key-file ] [--timeout 60] db contacts --account [--contains ] [--include-content] [--key-file ] [--timeout 60] Chat commands default to File Transfer Assistant; send/monitor and listener-smoke accept --session. diff --git a/src/WxAgent.Windows/SqlCipherDatabaseReader.cs b/src/WxAgent.Windows/SqlCipherDatabaseReader.cs index d166dd2..3077596 100644 --- a/src/WxAgent.Windows/SqlCipherDatabaseReader.cs +++ b/src/WxAgent.Windows/SqlCipherDatabaseReader.cs @@ -37,24 +37,12 @@ public static class SqlCipherDatabaseReader } } - var writesRejected = false; - try - { - var probeName = "__wxagent_read_only_probe_" + Guid.NewGuid().ToString("N"); - await using var command = connection.CreateCommand(); - // nosemgrep:csharp-sqli - The name is a random GUID produced above; it cannot be attacker-controlled. - command.CommandText = $"CREATE TABLE {probeName}(value INTEGER);"; - command.CommandTimeout = 10; - await command.ExecuteNonQueryAsync(cancellationToken); - } - catch (SqliteException exception) when (exception.SqliteErrorCode == 8) - { - writesRejected = true; - } + // Inspect SQLite's connection state; never attempt writes against the user's database. + var writesRejected = SQLitePCL.raw.sqlite3_db_readonly(connection.Handle, "main") == 1; if (!writesRejected) { - throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "Read-only SQLCipher connection unexpectedly accepted a write."); + throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher connection did not report read-only mode."); } return new DatabaseMetadata(cipherVersion, sqliteVersion, count, names, writesRejected); @@ -118,6 +106,12 @@ public static class SqlCipherDatabaseReader private static async Task OpenReadOnlyAsync(string databasePath, string hexKey, CancellationToken cancellationToken) { + var page = new byte[SqlCipherPageVerifier.PageSize]; + await using (var file = new FileStream(databasePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, + SqlCipherPageVerifier.PageSize, FileOptions.Asynchronous)) + await file.ReadExactlyAsync(page, cancellationToken).ConfigureAwait(false); + if (!SqlCipherPageVerifier.VerifyHexKey(page, hexKey)) + throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "The cached key failed current database page-1 HMAC verification; refresh the selected account key."); var connectionString = new SqliteConnectionStringBuilder { DataSource = Path.GetFullPath(databasePath), @@ -128,9 +122,9 @@ public static class SqlCipherDatabaseReader }.ToString(); var connection = new SqliteConnection(connectionString); - await connection.OpenAsync(cancellationToken); try { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); await using (var command = connection.CreateCommand()) { // nosemgrep:csharp-sqli - hexKey is validated to exactly 64 hex chars before this call; no injection is possible. diff --git a/src/WxAgent.Windows/WechatMessageDbReader.cs b/src/WxAgent.Windows/WechatMessageDbReader.cs index fb50857..75316b4 100644 --- a/src/WxAgent.Windows/WechatMessageDbReader.cs +++ b/src/WxAgent.Windows/WechatMessageDbReader.cs @@ -7,7 +7,7 @@ namespace WxAgent.Windows; public static class WechatMessageDbReader { public static async Task> ReadAsync( - string accountRootPath, IReadOnlyList databases, string chatId, int limit, CancellationToken cancellationToken) + string accountRootPath, IReadOnlyList databases, string chatId, int limit, CancellationToken cancellationToken, long? localId = null) { ArgumentException.ThrowIfNullOrWhiteSpace(accountRootPath); ArgumentException.ThrowIfNullOrWhiteSpace(chatId); @@ -30,9 +30,10 @@ public static class WechatMessageDbReader var sql = "SELECT m.local_id, m.server_id, m.local_type, m.create_time, " + "hex(m.message_content) AS hex_content, m.WCDB_CT_message_content AS is_compressed, n.user_name AS sender_wxid " + $"FROM \"{tableName}\" m LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " + - "ORDER BY m.create_time DESC LIMIT $limit;"; + "WHERE ($localId IS NULL OR m.local_id = $localId) " + + "ORDER BY m.create_time DESC, m.local_id DESC LIMIT $limit;"; var rows = await SqlCipherDatabaseReader.QueryRowsAsync(messageDatabase.Value.Path, messageDatabase.Value.EncKey, sql, - [new KeyValuePair("$limit", (long)limit)], cancellationToken).ConfigureAwait(false); + [new KeyValuePair("$limit", (long)limit), new KeyValuePair("$localId", localId)], cancellationToken).ConfigureAwait(false); var senders = rows.Select(row => row.GetValueOrDefault("sender_wxid") as string) .Where(sender => !string.IsNullOrEmpty(sender)).Cast().Distinct(StringComparer.Ordinal).ToArray(); @@ -53,7 +54,7 @@ public static class WechatMessageDbReader senderWxId, contact?.Name, contact?.AvatarUrl, - Convert.ToInt32(row.GetValueOrDefault("local_type") ?? 0, CultureInfo.InvariantCulture), + Convert.ToInt64(row.GetValueOrDefault("local_type") ?? 0L, CultureInfo.InvariantCulture), content, DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(row.GetValueOrDefault("create_time") ?? 0L, CultureInfo.InvariantCulture)), WechatDbMessage.IsSelf(senderWxId, accountRootDirectoryName))); @@ -62,6 +63,29 @@ public static class WechatMessageDbReader return messages; } + public static async Task ReadMergedAsync(string accountRootPath, IReadOnlyList databases, + string chatId, long localId, CancellationToken cancellationToken, string? databaseRelativePath = null) + { + if (localId <= 0) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A positive local message ID is required."); + var candidates = databases.Where(database => database.RelativePath.StartsWith("message/", StringComparison.OrdinalIgnoreCase) + && (databaseRelativePath is null || string.Equals(database.RelativePath, databaseRelativePath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))).ToArray(); + if (candidates.Length == 0) throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No matching message database has a cached key."); + (string Path, DbMessage Message)? found = null; + foreach (var database in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + var selected = databases.Where(entry => !entry.RelativePath.StartsWith("message/", StringComparison.OrdinalIgnoreCase) || entry == database).ToArray(); + var rows = await ReadAsync(accountRootPath, selected, chatId, 2, cancellationToken, localId).ConfigureAwait(false); + if (rows.Count == 0) continue; + if (rows.Count != 1 || found is not null) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The local ID is ambiguous across message shards; specify --database."); + found = (database.RelativePath, rows[0]); + } + if (found is null) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The selected database message was not found."); + try { return new DbMergedChat(found.Value.Path, found.Value.Message, WechatMergedChatParser.Parse(found.Value.Message.Content)); } + catch (InvalidDataException ex) { throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, ex.Message, ex); } + } + private static async Task> ReadContactsAsync( string accountRootPath, IReadOnlyList databases, IReadOnlyList senders, CancellationToken cancellationToken) { @@ -100,7 +124,11 @@ public static class WechatMessageDbReader foreach (var database in candidates) { cancellationToken.ThrowIfCancellationRequested(); - var path = Path.GetFullPath(Path.Combine(accountRootPath, database.RelativePath.Replace('/', Path.DirectorySeparatorChar))); + var root = Path.GetFullPath(accountRootPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + var relative = database.RelativePath.Replace('/', Path.DirectorySeparatorChar); + var path = Path.GetFullPath(Path.Combine(root, relative)); + if (Path.IsPathRooted(relative) || !path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The database path must remain inside the selected account root."); var sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name;"; var rows = await SqlCipherDatabaseReader.QueryRowsAsync(path, database.EncKey, sql, [new KeyValuePair("$name", tableName)], cancellationToken).ConfigureAwait(false); diff --git a/tests/WxAgent.Core.Tests/WechatMergedChatTests.cs b/tests/WxAgent.Core.Tests/WechatMergedChatTests.cs new file mode 100644 index 0000000..e6de4d5 --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatMergedChatTests.cs @@ -0,0 +1,84 @@ +using System.Text; +using System.Text.Json; +using System.Xml.Linq; +using WxAgent.Core; +using Xunit; +using ZstdSharp; + +namespace WxAgent.Core.Tests; + +public sealed class WechatMergedChatTests +{ + [Fact] + public void ParsesEmbeddedTextMetadataWithoutDeduplicatingRepeatedMessages() + { + var result = WechatMergedChatParser.Parse(Wrap(Record(Item("same\n\ntext"), Item("same\n\ntext")))); + Assert.Equal(2, result.Messages.Count); + Assert.Equal("0", result.Messages[0].Path); + Assert.Equal("1", result.Messages[1].Path); + Assert.Equal("same\n\ntext", result.Messages[0].Text); + Assert.Equal("Synthetic sender", result.Messages[0].SenderName); + Assert.Equal("opaque-hash", result.Messages[0].SenderHash); + Assert.Equal("18446744073709551615", result.Messages[0].SourceServerId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1700000000), result.Messages[0].Timestamp); + } + + [Fact] + public void SupportsNestedRecordsAndExplicitAttachmentMetadata() + { + var attachment = new XElement("dataitem", new XAttribute("datatype", 8), + new XElement("datatitle", "example.txt"), new XElement("datafmt", "txt"), new XElement("datasize", 42), + new XElement("cdn_dataurl", "must-not-be-exported"), new XElement("aeskey", "must-not-be-exported")); + var nested = new XElement("dataitem", new XAttribute("datatype", 17), Record(Item("nested"))); + var result = WechatMergedChatParser.Parse(Record(attachment, nested).ToString()); + Assert.Null(result.Messages[0].Text); + Assert.Equal(42, result.Messages[0].SizeBytes); + Assert.Equal("1/0", result.Messages[1].NestedRecord!.Messages[0].Path); + Assert.DoesNotContain("must-not-be-exported", JsonSerializer.Serialize(result)); + } + + [Fact] + public void RejectsTruncationMissingPayloadAndInvalidTimestamp() + { + var record = Record(Item("hello")); + record.Element("datalist")!.SetAttributeValue("count", 2); + Assert.Throws(() => WechatMergedChatParser.Parse(record.ToString())); + Assert.Throws(() => WechatMergedChatParser.Parse(Record(new XElement("dataitem", new XAttribute("datatype", 1))).ToString())); + var item = Item("hello"); + item.Element("srcMsgCreateTime")!.Value = long.MaxValue.ToString(); + Assert.Throws(() => WechatMergedChatParser.Parse(Record(item).ToString())); + } + + [Fact] + public void RejectsDtdAndBoundViolations() + { + Assert.Throws(() => WechatMergedChatParser.Parse("]>&x;")); + Assert.Throws(() => WechatMergedChatParser.Parse(new string('x', WechatMergedChatParser.MaxXmlCharacters + 1))); + var nested = Record(Item("innermost")); + for (var i = 0; i < WechatMergedChatParser.MaxNesting; i++) + nested = Record(new XElement("dataitem", new XAttribute("datatype", 17), nested)); + Assert.Throws(() => WechatMergedChatParser.Parse(nested.ToString())); + Assert.Throws(() => WechatMergedChatParser.Parse(Record(Enumerable.Range(0, WechatMergedChatParser.MaxMessages + 1).Select(_ => Item("x")).ToArray()).ToString())); + } + + [Fact] + public void PreservesComposite64BitMessageTypeAndBoundsZstdDecoding() + { + var message = new DbMessage(149, 123, "filehelper", null, null, null, 81604378673L, "xml", DateTimeOffset.UnixEpoch, null); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(message)); + Assert.Equal(81604378673L, json.RootElement.GetProperty("Type").GetInt64()); + using var output = new MemoryStream(); + using (var compressed = new CompressionStream(output)) + compressed.Write(Encoding.UTF8.GetBytes(new string('x', WechatMergedChatParser.MaxXmlCharacters + 1))); + Assert.Throws(() => WechatDbMessage.DecodeContent(Convert.ToHexString(output.ToArray()), true)); + } + + private static XElement Item(string text) => new("dataitem", new XAttribute("datatype", 1), new XAttribute("dataid", "repeated-id"), + new XElement("datadesc", text), new XElement("sourcename", "Synthetic sender"), new XElement("srcMsgLocalId", "7"), + new XElement("srcMsgCreateTime", "1700000000"), new XElement("fromnewmsgid", "18446744073709551615"), + new XElement("dataitemsource", new XElement("hashusername", "opaque-hash"))); + private static XElement Record(params XElement[] items) => new("recordinfo", new XElement("title", "Synthetic record"), + new XElement("datalist", new XAttribute("count", items.Length), items)); + private static string Wrap(XElement record) => new XElement("msg", new XElement("appmsg", new XElement("type", 19), + new XElement("recorditem", new XCData(record.ToString(SaveOptions.DisableFormatting))))).ToString(); +}