using System; using System.IO; using System.Reflection; using System.Text; using DCIT.Core.Models; using DCIT.Core.Utilities; using Newtonsoft.Json; namespace DCIT.Core.Services { public class DiffSerializationService { private static readonly byte[] PayloadCiphertextPropertyMarker = Encoding.UTF8.GetBytes("\"PayloadCiphertext\":"); private static readonly byte[] PayloadCiphertextValueMarker = Encoding.UTF8.GetBytes("\"PayloadCiphertext\":\""); private static readonly byte[] MetadataSyntheticSuffix = Encoding.UTF8.GetBytes("null,\"Payload\":null}"); private const int MetadataScanCapBytes = 1 << 20; private readonly BmpDiffCodec _bmpDiffCodec; private readonly string _appVersion; public DiffSerializationService(BmpDiffCodec bmpDiffCodec) { _bmpDiffCodec = bmpDiffCodec; _appVersion = Assembly.GetExecutingAssembly().GetName().Version != null ? Assembly.GetExecutingAssembly().GetName().Version.ToString() : "1.0.0"; } public string AppVersion { get { return _appVersion; } } public string GetBmpPath(string diffPath) { return Path.ChangeExtension(diffPath, ".bmp"); } public string Write(DiffDocument plainDiff, string diffPath, bool encryptJson) { if (plainDiff == null) { throw new ArgumentNullException("plainDiff"); } if (plainDiff.Payload == null) { throw new InvalidOperationException("差异载荷不能为空。"); } var originalPayload = plainDiff.Payload; var originalEncrypted = plainDiff.Encrypted; var originalEncryption = plainDiff.Encryption; var originalPayloadCiphertext = plainDiff.PayloadCiphertext; string payloadTmpPath = null; string cipherTmpPath = null; try { payloadTmpPath = Path.Combine(Path.GetTempPath(), "dcit-payload-" + Guid.NewGuid().ToString("N") + ".bin"); SerializeInstanceToUtf8File(plainDiff.Payload, payloadTmpPath); plainDiff.Integrity.PayloadHash = HashUtility.ComputeFileSha256Base64(payloadTmpPath); plainDiff.Integrity.TamperProtectionValue = HashUtility.ComputeFileHmacBase64(payloadTmpPath); plainDiff.Encrypted = false; plainDiff.Encryption = null; plainDiff.PayloadCiphertext = null; Directory.CreateDirectory(Path.GetDirectoryName(diffPath) ?? string.Empty); var bmpPath = GetBmpPath(diffPath); // Serialize the full plainDiff (with Payload attached) to BMP. Use a temp file so we // never hold the whole BMP-bound JSON in memory. var bmpTmpPath = Path.Combine(Path.GetTempPath(), "dcit-bmp-" + Guid.NewGuid().ToString("N") + ".json"); try { SerializeInstanceToUtf8File(plainDiff, bmpTmpPath); using (var bmpFile = File.OpenRead(bmpTmpPath)) { _bmpDiffCodec.EncodeFromStream(bmpFile, bmpPath); } } finally { TryDeleteFile(bmpTmpPath); } if (encryptJson) { cipherTmpPath = Path.Combine(Path.GetTempPath(), "dcit-cipher-" + Guid.NewGuid().ToString("N") + ".bin"); EncryptedPayloadHeader header; using (var payloadStream = File.OpenRead(payloadTmpPath)) using (var cipherStream = File.Create(cipherTmpPath)) { header = HashUtility.EncryptStream(payloadStream, cipherStream); } plainDiff.Encrypted = true; plainDiff.Encryption = new DiffEncryptionInfo { Algorithm = "AES-256-CBC+HMAC-SHA256", KeyId = "default", Nonce = header.NonceBase64, Tag = header.TagBase64, }; plainDiff.PayloadCiphertext = null; plainDiff.Payload = null; WriteEncryptedDiffToFile(plainDiff, diffPath, cipherTmpPath); } else { SerializeToFile(plainDiff, diffPath); } return bmpPath; } finally { plainDiff.Payload = originalPayload; plainDiff.Encrypted = originalEncrypted; plainDiff.Encryption = originalEncryption; plainDiff.PayloadCiphertext = originalPayloadCiphertext; TryDeleteFile(payloadTmpPath); TryDeleteFile(cipherTmpPath); } } private static void SerializeInstanceToUtf8File(object instance, string path) { using (var stream = File.Create(path)) using (var streamWriter = new StreamWriter(stream, new UTF8Encoding(false), bufferSize: 65536)) using (var jsonWriter = new JsonTextWriter(streamWriter)) { var serializer = JsonSerializer.CreateDefault(); serializer.Formatting = Formatting.None; serializer.Serialize(jsonWriter, instance); jsonWriter.Flush(); } } private static void WriteEncryptedDiffToFile(DiffDocument document, string diffPath, string cipherTempPath) { var metadataJson = JsonConvert.SerializeObject(document, Formatting.None); const string placeholder = "\"PayloadCiphertext\":null"; var placeholderIndex = metadataJson.IndexOf(placeholder, StringComparison.Ordinal); if (placeholderIndex < 0) { throw new InvalidOperationException("加密差异元数据缺少 PayloadCiphertext 占位符。"); } using (var fileStream = File.Create(diffPath)) using (var writer = new StreamWriter(fileStream, new UTF8Encoding(false), bufferSize: 65536)) { writer.Write(metadataJson.Substring(0, placeholderIndex)); writer.Write("\"PayloadCiphertext\":\""); using (var cipherStream = File.OpenRead(cipherTempPath)) { var buffer = new byte[30720]; int read; while ((read = cipherStream.Read(buffer, 0, buffer.Length)) > 0) { writer.Write(Convert.ToBase64String(buffer, 0, read)); } } writer.Write('"'); writer.Write(metadataJson.Substring(placeholderIndex + placeholder.Length)); } } private static void TryDeleteFile(string path) { if (string.IsNullOrWhiteSpace(path)) { return; } try { if (File.Exists(path)) { File.Delete(path); } } catch { // Best-effort cleanup; temp files live in the system temp dir. } } public LoadedDiffDocument Load(string diffOrBmpPath) { if (diffOrBmpPath == null) { throw new ArgumentNullException("diffOrBmpPath"); } if (Path.GetExtension(diffOrBmpPath).Equals(".bmp", StringComparison.OrdinalIgnoreCase)) { return LoadFromBmp(diffOrBmpPath); } return LoadFromDiff(diffOrBmpPath); } public DiffDocument ReadMetadata(string diffOrBmpPath) { if (diffOrBmpPath == null) { throw new ArgumentNullException("diffOrBmpPath"); } if (Path.GetExtension(diffOrBmpPath).Equals(".bmp", StringComparison.OrdinalIgnoreCase)) { var json = _bmpDiffCodec.DecodeJson(diffOrBmpPath); return JsonConvert.DeserializeObject(json) ?? new DiffDocument(); } return ReadDiffMetadata(diffOrBmpPath); } public DiffDocument CreateBaseDocument() { return new DiffDocument { App = new DiffAppInfo { Name = "DCIT", Version = _appVersion, }, Algorithm = new DiffAlgorithmInfo { DiffVersion = "1.0", BmpCodecVersion = "1.0", }, }; } // BMP diffs use a legacy in-memory load path. Large BMP-encoded diffs remain a known // OOM risk; the default and reported path is .diff, which is fully streaming below. private LoadedDiffDocument LoadFromBmp(string bmpPath) { var json = _bmpDiffCodec.DecodeJson(bmpPath); var document = JsonConvert.DeserializeObject(json); if (document == null) { throw new InvalidDataException("差异文件解析失败。"); } if (document.Encrypted) { if (document.Encryption == null || string.IsNullOrWhiteSpace(document.PayloadCiphertext)) { throw new InvalidDataException("加密差异文件缺少必要字段。"); } var payloadBytes = HashUtility.Decrypt( document.PayloadCiphertext, document.Encryption.Nonce, document.Encryption.Tag); var payloadJson = Encoding.UTF8.GetString(payloadBytes); document.Payload = JsonConvert.DeserializeObject(payloadJson); ValidateIntegrity(document, payloadBytes); } else { if (document.Payload == null) { throw new InvalidDataException("明文差异文件缺少 payload。"); } ValidateIntegrity(document, Encoding.UTF8.GetBytes(SerializeCanonical(document.Payload))); } return new LoadedDiffDocument { Document = document, }; } private LoadedDiffDocument LoadFromDiff(string diffPath) { var document = ReadDiffMetadata(diffPath); string payloadTmpPath = null; string cipherTmpPath = null; try { if (document.Encrypted) { if (document.Encryption == null || string.IsNullOrWhiteSpace(document.Encryption.Nonce) || string.IsNullOrWhiteSpace(document.Encryption.Tag)) { throw new InvalidDataException("加密差异文件缺少加密元数据。"); } cipherTmpPath = Path.Combine(Path.GetTempPath(), "dcit-rcipher-" + Guid.NewGuid().ToString("N") + ".bin"); payloadTmpPath = Path.Combine(Path.GetTempPath(), "dcit-rpayload-" + Guid.NewGuid().ToString("N") + ".bin"); ExtractBase64RegionToFile(diffPath, PayloadCiphertextValueMarker, cipherTmpPath); var iv = Convert.FromBase64String(document.Encryption.Nonce); var tag = Convert.FromBase64String(document.Encryption.Tag); using (var cipherStream = File.OpenRead(cipherTmpPath)) using (var payloadStream = File.Create(payloadTmpPath)) { HashUtility.DecryptStream(cipherStream, payloadStream, iv, tag); } ValidateIntegrityFromPayloadFile(document, payloadTmpPath); document.Payload = DeserializePayloadFromFile(payloadTmpPath); } else { // Stream-deserialize the whole document. PayloadCiphertext is null (small) and // Payload is a large nested object that Newtonsoft builds incrementally without // buffering the whole JSON text or any single huge string token. var full = StreamDeserializeDiffDocument(diffPath); if (full == null) { throw new InvalidDataException("差异文件解析失败。"); } if (full.Payload == null) { throw new InvalidDataException("明文差异文件缺少 payload。"); } document = full; payloadTmpPath = Path.Combine(Path.GetTempPath(), "dcit-rpayload-" + Guid.NewGuid().ToString("N") + ".bin"); SerializeInstanceToUtf8File(document.Payload, payloadTmpPath); ValidateIntegrityFromPayloadFile(document, payloadTmpPath); } return new LoadedDiffDocument { Document = document, }; } finally { TryDeleteFile(payloadTmpPath); TryDeleteFile(cipherTmpPath); } } // Reads only the metadata portion of a .diff file. The huge fields (PayloadCiphertext value // and Payload value) sit after the metadata in declaration order; we byte-scan up to the // "PayloadCiphertext" property marker (a few KB), synthesize null values for the two huge // fields, and parse the resulting small metadata JSON. The huge content is never read. private DiffDocument ReadDiffMetadata(string diffPath) { var prefix = ReadPrefixThroughMarker(diffPath, PayloadCiphertextPropertyMarker, MetadataScanCapBytes); string metadataJson; if (prefix == null) { metadataJson = File.ReadAllText(diffPath, Encoding.UTF8); } else { var combined = new byte[prefix.Length + MetadataSyntheticSuffix.Length]; Buffer.BlockCopy(prefix, 0, combined, 0, prefix.Length); Buffer.BlockCopy(MetadataSyntheticSuffix, 0, combined, prefix.Length, MetadataSyntheticSuffix.Length); metadataJson = Encoding.UTF8.GetString(combined); } return JsonConvert.DeserializeObject(metadataJson) ?? new DiffDocument(); } private static DiffDocument StreamDeserializeDiffDocument(string diffPath) { using (var stream = File.OpenRead(diffPath)) using (var streamReader = new StreamReader(stream, new UTF8Encoding(false))) using (var jsonReader = new JsonTextReader(streamReader)) { return JsonSerializer.CreateDefault().Deserialize(jsonReader); } } private static DiffPayload DeserializePayloadFromFile(string payloadPath) { using (var stream = File.OpenRead(payloadPath)) using (var streamReader = new StreamReader(stream, new UTF8Encoding(false))) using (var jsonReader = new JsonTextReader(streamReader)) { return JsonSerializer.CreateDefault().Deserialize(jsonReader); } } private static void ValidateIntegrity(DiffDocument document, byte[] payloadBytes) { var actualHash = HashUtility.ComputeSha256Base64(payloadBytes); var actualTamper = HashUtility.ComputeHmacBase64(payloadBytes); if (!string.Equals(actualHash, document.Integrity == null ? null : document.Integrity.PayloadHash, StringComparison.Ordinal)) { throw new InvalidDataException("差异文件载荷哈希校验失败。"); } if (!string.Equals(actualTamper, document.Integrity == null ? null : document.Integrity.TamperProtectionValue, StringComparison.Ordinal)) { throw new InvalidDataException("差异文件防篡改校验失败。"); } } private static void ValidateIntegrityFromPayloadFile(DiffDocument document, string payloadPath) { var actualHash = HashUtility.ComputeFileSha256Base64(payloadPath); var actualTamper = HashUtility.ComputeFileHmacBase64(payloadPath); if (!string.Equals(actualHash, document.Integrity == null ? null : document.Integrity.PayloadHash, StringComparison.Ordinal)) { throw new InvalidDataException("差异文件载荷哈希校验失败。"); } if (!string.Equals(actualTamper, document.Integrity == null ? null : document.Integrity.TamperProtectionValue, StringComparison.Ordinal)) { throw new InvalidDataException("差异文件防篡改校验失败。"); } } private static string SerializeCanonical(object instance) { return JsonConvert.SerializeObject(instance, Formatting.None); } private static void SerializeToFile(object instance, string path) { using (var stream = File.Create(path)) using (var streamWriter = new StreamWriter(stream, new UTF8Encoding(false), bufferSize: 65536)) using (var jsonWriter = new JsonTextWriter(streamWriter)) { var serializer = JsonSerializer.CreateDefault(); serializer.Formatting = Formatting.None; serializer.Serialize(jsonWriter, instance); jsonWriter.Flush(); } } // Reads bytes from the start of the file up to and including the first occurrence of marker. // Returns null if the marker is not found within cap bytes. Caller chooses cap so the marker // (which sits after the small metadata block) is found without reading huge payload content. private static byte[] ReadPrefixThroughMarker(string path, byte[] marker, int cap) { using (var stream = File.OpenRead(path)) { var ms = new MemoryStream(); var buffer = new byte[65536]; int read; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) { ms.Write(buffer, 0, read); if (ms.Length >= cap) { break; } } var arr = ms.ToArray(); var idx = IndexOfSubarray(arr, marker); if (idx < 0) { return null; } var result = new byte[idx + marker.Length]; Buffer.BlockCopy(arr, 0, result, 0, result.Length); return result; } } // Locates valueMarker in the file, then stream-decodes the base64 region between the marker // and the next closing double-quote into outputPath. Decodes in 4096-char chunks (a multiple // of 4) so there is no mid-chunk base64 alignment error. private static void ExtractBase64RegionToFile(string diffPath, byte[] valueMarker, string outputPath) { long markerEndPosition; using (var probe = File.OpenRead(diffPath)) { markerEndPosition = FindMarkerEnd(probe, valueMarker); } if (markerEndPosition < 0) { throw new InvalidDataException("差异文件缺少密文字段标记。"); } using (var stream = File.OpenRead(diffPath)) { stream.Position = markerEndPosition; using (var outStream = File.Create(outputPath)) { var charBuf = new char[4096]; var charLen = 0; var byteBuf = new byte[65536]; int read; var done = false; while (!done && (read = stream.Read(byteBuf, 0, byteBuf.Length)) > 0) { for (var i = 0; i < read; i++) { var b = byteBuf[i]; if (b == '"') { done = true; break; } charBuf[charLen++] = (char)b; if (charLen == charBuf.Length) { var decoded = Convert.FromBase64CharArray(charBuf, 0, charLen); outStream.Write(decoded, 0, decoded.Length); charLen = 0; } } } if (charLen > 0) { var decoded = Convert.FromBase64CharArray(charBuf, 0, charLen); outStream.Write(decoded, 0, decoded.Length); } } } } private static long FindMarkerEnd(Stream stream, byte[] marker) { var ms = new MemoryStream(); var buffer = new byte[65536]; int read; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) { ms.Write(buffer, 0, read); var arr = ms.ToArray(); var idx = IndexOfSubarray(arr, marker); if (idx >= 0) { return idx + marker.Length; } if (ms.Length > MetadataScanCapBytes) { return -1; } } return -1; } private static int IndexOfSubarray(byte[] haystack, byte[] needle) { if (needle == null || needle.Length == 0) { return 0; } if (haystack == null || haystack.Length < needle.Length) { return -1; } for (var i = 0; i <= haystack.Length - needle.Length; i++) { var match = true; for (var j = 0; j < needle.Length; j++) { if (haystack[i + j] != needle[j]) { match = false; break; } } if (match) { return i; } } return -1; } } public class LoadedDiffDocument { public DiffDocument Document { get; set; } } }