|
| 1 | +using System.IO.Compression; |
| 2 | +using System.IO.Hashing; |
| 3 | +using System.Text; |
| 4 | +using System.Text.RegularExpressions; |
| 5 | + |
| 6 | +namespace JD.Efcpt.Build.Tasks; |
| 7 | + |
| 8 | +/// <summary> |
| 9 | +/// Computes a schema-based fingerprint for DACPAC files. |
| 10 | +/// </summary> |
| 11 | +/// <remarks> |
| 12 | +/// <para> |
| 13 | +/// A DACPAC is a ZIP archive containing schema metadata. Simply hashing the entire file |
| 14 | +/// produces different results for identical schemas because build-time metadata (file paths, |
| 15 | +/// timestamps) is embedded in the archive. |
| 16 | +/// </para> |
| 17 | +/// <para> |
| 18 | +/// This class extracts and normalizes the schema-relevant content: |
| 19 | +/// <list type="bullet"> |
| 20 | +/// <item><description><c>model.xml</c> - The schema definition, with path metadata normalized</description></item> |
| 21 | +/// <item><description><c>predeploy.sql</c> - Optional pre-deployment script</description></item> |
| 22 | +/// <item><description><c>postdeploy.sql</c> - Optional post-deployment script</description></item> |
| 23 | +/// </list> |
| 24 | +/// </para> |
| 25 | +/// <para> |
| 26 | +/// The implementation is based on the approach from ErikEJ/DacDeploySkip. |
| 27 | +/// </para> |
| 28 | +/// </remarks> |
| 29 | +internal static partial class DacpacFingerprint |
| 30 | +{ |
| 31 | + private const string ModelXmlEntry = "model.xml"; |
| 32 | + private const string PreDeployEntry = "predeploy.sql"; |
| 33 | + private const string PostDeployEntry = "postdeploy.sql"; |
| 34 | + |
| 35 | + /// <summary> |
| 36 | + /// Computes a fingerprint for the schema content within a DACPAC file. |
| 37 | + /// </summary> |
| 38 | + /// <param name="dacpacPath">Path to the DACPAC file.</param> |
| 39 | + /// <returns>A 16-character hexadecimal fingerprint string.</returns> |
| 40 | + /// <exception cref="FileNotFoundException">The DACPAC file does not exist.</exception> |
| 41 | + /// <exception cref="InvalidOperationException">The DACPAC does not contain a model.xml file.</exception> |
| 42 | + public static string Compute(string dacpacPath) |
| 43 | + { |
| 44 | + if (!File.Exists(dacpacPath)) |
| 45 | + throw new FileNotFoundException("DACPAC file not found.", dacpacPath); |
| 46 | + |
| 47 | + using var archive = ZipFile.OpenRead(dacpacPath); |
| 48 | + |
| 49 | + var hash = new XxHash64(); |
| 50 | + |
| 51 | + // Process model.xml (required) |
| 52 | + var modelEntry = archive.GetEntry(ModelXmlEntry) |
| 53 | + ?? throw new InvalidOperationException($"DACPAC does not contain {ModelXmlEntry}"); |
| 54 | + |
| 55 | + var normalizedModel = ReadAndNormalizeModelXml(modelEntry); |
| 56 | + hash.Append(normalizedModel); |
| 57 | + |
| 58 | + // Process optional pre-deployment script |
| 59 | + var preDeployEntry = archive.GetEntry(PreDeployEntry); |
| 60 | + if (preDeployEntry != null) |
| 61 | + { |
| 62 | + var preDeployContent = ReadEntryBytes(preDeployEntry); |
| 63 | + hash.Append(preDeployContent); |
| 64 | + } |
| 65 | + |
| 66 | + // Process optional post-deployment script |
| 67 | + var postDeployEntry = archive.GetEntry(PostDeployEntry); |
| 68 | + if (postDeployEntry != null) |
| 69 | + { |
| 70 | + var postDeployContent = ReadEntryBytes(postDeployEntry); |
| 71 | + hash.Append(postDeployContent); |
| 72 | + } |
| 73 | + |
| 74 | + return hash.GetCurrentHashAsUInt64().ToString("x16"); |
| 75 | + } |
| 76 | + |
| 77 | + /// <summary> |
| 78 | + /// Reads model.xml and normalizes metadata to remove build-specific paths. |
| 79 | + /// </summary> |
| 80 | + private static byte[] ReadAndNormalizeModelXml(ZipArchiveEntry entry) |
| 81 | + { |
| 82 | + using var stream = entry.Open(); |
| 83 | + using var reader = new StreamReader(stream, Encoding.UTF8); |
| 84 | + var content = reader.ReadToEnd(); |
| 85 | + |
| 86 | + // Normalize metadata values that contain full paths |
| 87 | + // These change between builds on different machines but don't affect the schema |
| 88 | + content = NormalizeMetadataPath(content, "FileName"); |
| 89 | + content = NormalizeMetadataPath(content, "AssemblySymbolsName"); |
| 90 | + |
| 91 | + return Encoding.UTF8.GetBytes(content); |
| 92 | + } |
| 93 | + |
| 94 | + /// <summary> |
| 95 | + /// Replaces full paths in Metadata elements with just the filename. |
| 96 | + /// </summary> |
| 97 | + /// <remarks> |
| 98 | + /// Matches patterns like: |
| 99 | + /// <code><Metadata Name="FileName" Value="C:\path\to\file.dacpac" /></code> |
| 100 | + /// and replaces with: |
| 101 | + /// <code><Metadata Name="FileName" Value="file.dacpac" /></code> |
| 102 | + /// </remarks> |
| 103 | + private static string NormalizeMetadataPath(string xml, string metadataName) |
| 104 | + // Pattern matches: <Metadata Name="FileName" Value="any/path/here" /> |
| 105 | + // or: <Metadata Name="FileName" Value="any\path\here" /> |
| 106 | + => MetadataRegex(metadataName).Replace(xml, match => |
| 107 | + { |
| 108 | + var prefix = match.Groups[1].Value; |
| 109 | + var fullPath = match.Groups[2].Value; |
| 110 | + var suffix = match.Groups[3].Value; |
| 111 | + |
| 112 | + // Extract just the filename from the path |
| 113 | + var fileName = GetFileName(fullPath); |
| 114 | + return $"{prefix}{fileName}{suffix}"; |
| 115 | + }); |
| 116 | + |
| 117 | + /// <summary> |
| 118 | + /// Extracts the filename from a path, handling both forward and back slashes. |
| 119 | + /// </summary> |
| 120 | + private static string GetFileName(string path) |
| 121 | + { |
| 122 | + if (string.IsNullOrEmpty(path)) |
| 123 | + return path; |
| 124 | + |
| 125 | + var lastSlash = path.LastIndexOfAny(['/', '\\']); |
| 126 | + return lastSlash >= 0 ? path[(lastSlash + 1)..] : path; |
| 127 | + } |
| 128 | + |
| 129 | + /// <summary> |
| 130 | + /// Reads all bytes from a ZIP archive entry. |
| 131 | + /// </summary> |
| 132 | + private static byte[] ReadEntryBytes(ZipArchiveEntry entry) |
| 133 | + { |
| 134 | + using var stream = entry.Open(); |
| 135 | + using var ms = new MemoryStream(); |
| 136 | + stream.CopyTo(ms); |
| 137 | + return ms.ToArray(); |
| 138 | + } |
| 139 | + |
| 140 | + |
| 141 | + private static Regex MetadataRegex(string metadataName) => metadataName switch |
| 142 | + { |
| 143 | + "FileName" => FileNameMetadataRegex(), |
| 144 | + "AssemblySymbolsName" => AssemblySymbolsMetadataRegex(), |
| 145 | + _ => new Regex($"""(<Metadata\s+Name="{metadataName}"\s+Value=")([^"]+)(")""", RegexOptions.Compiled) |
| 146 | + }; |
| 147 | + |
| 148 | + /// <summary> |
| 149 | + /// Regex for matching Metadata elements with specific Name attributes. |
| 150 | + /// </summary> |
| 151 | + [GeneratedRegex("""(<Metadata\s+Name="FileName"\s+Value=")([^"]+)(")""", RegexOptions.Compiled)] |
| 152 | + private static partial Regex FileNameMetadataRegex(); |
| 153 | + |
| 154 | + [GeneratedRegex("""(<Metadata\s+Name="AssemblySymbolsName"\s+Value=")([^"]+)(")""", RegexOptions.Compiled)] |
| 155 | + private static partial Regex AssemblySymbolsMetadataRegex(); |
| 156 | + |
| 157 | +} |
0 commit comments