593 lines
23 KiB
C#
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; }
|
|
}
|
|
}
|