feat: expand merged chat records directly from read-only databases
Add db merged with exact account/chat/local-ID selection and shard ambiguity checks. Preserve Int64 composite message types and parse bounded, DTD-free embedded record XML, including duplicate messages and source metadata. Revalidate cached keys against current page-1 HMAC; replace the SQL write probe with sqlite3_db_readonly. Core tests: 129 passed. Windows Session 0 database validation: 5/5; all 16 text/sender/source-ID/timestamp entries matched independent XML parsing. Read-only UI baseline: 3/3; zero sends.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# 数据库合并聊天记录展开 — 2026-09-06
|
||||
|
||||
## 调用方式
|
||||
|
||||
```text
|
||||
WxAgent.Host db merged --account <account-fingerprint> --chat filehelper --local-id 149
|
||||
WxAgent.Host db merged --account <account-fingerprint> --chat filehelper --local-id 149 --include-content
|
||||
```
|
||||
|
||||
可选 `--database <relative-path>` 消除不同消息分库 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,不补造身份或时间。
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"Checks": [
|
||||
{
|
||||
"Stage": "doctor",
|
||||
"ExitCode": 0
|
||||
},
|
||||
{
|
||||
"Stage": "inspect-ui",
|
||||
"ExitCode": 0
|
||||
},
|
||||
{
|
||||
"Stage": "smoke-readonly",
|
||||
"ExitCode": 0
|
||||
}
|
||||
],
|
||||
"SendsAttempted": 0,
|
||||
"Success": true
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"error": "InvalidArgument",
|
||||
"Message": "--local-id must be a positive 64-bit integer."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"error": "ControlNotFound",
|
||||
"Message": "The selected database message was not found."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"WritesRejected": true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"MessageCount": 16,
|
||||
"ExactTextMatches": 16,
|
||||
"ExactSenderMatches": 16,
|
||||
"ExactSourceIdMatches": 16,
|
||||
"ExactTimestampMatches": 16,
|
||||
"IndependentParser": "Python xml.etree.ElementTree",
|
||||
"ContentIncluded": false
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<WechatMergedMessage> 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<WechatMergedMessage>(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);
|
||||
}
|
||||
@@ -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 <path>] [--timeout 30]
|
||||
db query --account <fingerprint> --database <relative-path> [--key-file <path>] [--timeout 30]
|
||||
db messages --account <fingerprint> --chat <wxid> [--limit 50] [--include-content] [--key-file <path>] [--timeout 60]
|
||||
db merged --account <fingerprint> --chat <wxid> --local-id <id> [--database <relative-path>] [--include-content] [--key-file <path>] [--timeout 60]
|
||||
db contacts --account <fingerprint> [--contains <text>] [--include-content] [--key-file <path>] [--timeout 60]
|
||||
|
||||
Chat commands default to File Transfer Assistant; send/monitor and listener-smoke accept --session.
|
||||
|
||||
@@ -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<SqliteConnection> 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.
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace WxAgent.Windows;
|
||||
public static class WechatMessageDbReader
|
||||
{
|
||||
public static async Task<IReadOnlyList<DbMessage>> ReadAsync(
|
||||
string accountRootPath, IReadOnlyList<DatabaseKeyEvidence> databases, string chatId, int limit, CancellationToken cancellationToken)
|
||||
string accountRootPath, IReadOnlyList<DatabaseKeyEvidence> 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<string, object?>("$limit", (long)limit)], cancellationToken).ConfigureAwait(false);
|
||||
[new KeyValuePair<string, object?>("$limit", (long)limit), new KeyValuePair<string, object?>("$localId", localId)], cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var senders = rows.Select(row => row.GetValueOrDefault("sender_wxid") as string)
|
||||
.Where(sender => !string.IsNullOrEmpty(sender)).Cast<string>().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<DbMergedChat> ReadMergedAsync(string accountRootPath, IReadOnlyList<DatabaseKeyEvidence> 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<IReadOnlyDictionary<string, WechatContactName>> ReadContactsAsync(
|
||||
string accountRootPath, IReadOnlyList<DatabaseKeyEvidence> databases, IReadOnlyList<string> 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<string, object?>("$name", tableName)], cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -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<InvalidDataException>(() => WechatMergedChatParser.Parse(record.ToString()));
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(new XElement("dataitem", new XAttribute("datatype", 1))).ToString()));
|
||||
var item = Item("hello");
|
||||
item.Element("srcMsgCreateTime")!.Value = long.MaxValue.ToString();
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse(Record(item).ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsDtdAndBoundViolations()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => WechatMergedChatParser.Parse("<!DOCTYPE recordinfo [<!ENTITY x SYSTEM 'file:///not-read'>]><recordinfo>&x;</recordinfo>"));
|
||||
Assert.Throws<InvalidDataException>(() => 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<InvalidDataException>(() => WechatMergedChatParser.Parse(nested.ToString()));
|
||||
Assert.Throws<InvalidDataException>(() => 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<InvalidDataException>(() => 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();
|
||||
}
|
||||
Reference in New Issue
Block a user