Files
DCIT/DCIT.Core/Services/DiffSerializationService.cs
2026-07-13 10:04:36 +08:00

593 lines
23 KiB
C#

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<DiffDocument>(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<DiffDocument>(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<DiffPayload>(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<DiffDocument>(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<DiffDocument>(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<DiffPayload>(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; }
}
}