118 lines
6.1 KiB
C#
118 lines
6.1 KiB
C#
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);
|
|
}
|