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); }