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:
2026-09-06 20:10:04 +08:00
parent e5548bd471
commit 30df7ae433
15 changed files with 775 additions and 24 deletions
+14 -3
View File
@@ -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;
}
+117
View File
@@ -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);
}