Просмотр исходного кода

fix(migration): make script hashes line-ending stable

- accept LF/CRLF-equivalent hashes for executed migrations
- store canonical LF hashes for future migrations
- keep substantive migration mutations blocked
- enforce LF checkout for migration SQL
- add regression tests

chore: bump version server 1.0.396
YY968XX 13 часов назад
Родитель
Сommit
b9b4ac45da

+ 5 - 0
.gitattributes

@@ -1,5 +1,10 @@
 * text=auto eol=lf
 
+# Migration SQL — 显式钉死 LF,勿依赖上面的 text=auto。
+# migration 守卫按文件内容算 SHA256;一旦某个平台检出成 CRLF,
+# 「A 机器执行、B 机器启动」就会被误判为脚本被篡改而拒绝启动。
+server/Admin.NET.Web.Entry/UpdateScripts/*.sql text eol=lf
+
 # Binary files — do not apply line-ending conversion
 *.png binary
 *.jpg binary

+ 9 - 6
server/Admin.NET.Core/Update/AutoVersionUpdate.cs

@@ -130,7 +130,9 @@ public static class AutoVersionUpdate
         {
             if (string.Equals(row.Status, StatusSuccess, StringComparison.OrdinalIgnoreCase))
             {
-                if (!string.Equals(row.FileHash, script.Hash, StringComparison.OrdinalIgnoreCase))
+                // 只容忍 CRLF/CR/LF 行尾差异(同一文件在 Windows 与 Linux 检出字节不同);
+                // SQL 字符内容一旦变化仍然抛错,历史脚本不可被静默改写。
+                if (!MigrationScriptHash.MatchesFile(row.FileHash, script.FilePath))
                 {
                     throw new InvalidOperationException(
                         $"版本脚本 {script.FileName} 的 SHA256 与已成功记录不一致,禁止静默覆盖。请新建更高版本脚本。");
@@ -454,12 +456,13 @@ public static class AutoVersionUpdate
         return version;
     }
 
+    /// <summary>
+    /// 迁移脚本 hash 一律用 LF 归一化结果(<see cref="MigrationScriptHash.ComputeCanonical"/>),
+    /// 使 Windows / Linux / macOS 执行同一文件时写入数据库的 hash 一致。
+    /// 历史上已写入的 raw CRLF / raw LF 记录由 <see cref="MigrationScriptHash.Matches"/> 继续兼容。
+    /// </summary>
     private static string ComputeSha256(string filePath)
-    {
-        using var sha = SHA256.Create();
-        using var stream = File.OpenRead(filePath);
-        return Convert.ToHexString(sha.ComputeHash(stream));
-    }
+        => MigrationScriptHash.ComputeCanonicalFromFile(filePath);
 
     private static string? GetDatabaseName(ISqlSugarClient db)
     {

+ 107 - 0
server/Admin.NET.Core/Update/MigrationScriptHash.cs

@@ -0,0 +1,107 @@
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+using System.Security.Cryptography;
+
+namespace Admin.NET.Core.Update;
+
+/// <summary>
+/// 迁移脚本 SHA256 计算与跨平台行尾兼容校验。
+///
+/// 背景:同一份 migration SQL 在 Windows(CRLF) 与 Linux/macOS(LF) 检出时字节不同,
+/// 直接对文件原始字节做 SHA256 会得到不同结果。于是「A 机器执行、B 机器启动」时,
+/// 守卫会把纯粹的行尾差异误判成脚本被篡改,导致 B 机器无法启动。
+///
+/// 本类的取舍:
+///  · 只容忍行尾差异(CRLF / CR / LF 互转);
+///  · SQL 字符内容一旦发生任何变化(哪怕一个空格、一个 token),仍然判定为不一致;
+///  · 不做 Trim、不去空白、不去注释、不做 SQL 格式化。
+///
+/// 新记录一律持久化 <see cref="ComputeCanonical"/>(LF 归一化)结果,
+/// 使后续任意平台执行同一文件都得到相同 hash;历史上已写入的
+/// raw-LF / raw-CRLF 记录继续被 <see cref="Matches"/> 接受,不需要回改数据库。
+/// </summary>
+[SuppressSniffer]
+public static class MigrationScriptHash
+{
+    /// <summary>文件原始字节的 SHA256(历史记录可能是这个)。</summary>
+    public static string ComputeRaw(byte[] content) => Sha256(content);
+
+    /// <summary>行尾归一化为 LF 后的 SHA256。新记录一律用它,跨平台稳定。</summary>
+    public static string ComputeCanonical(byte[] content) => Sha256(NormalizeToLf(content));
+
+    /// <summary>行尾归一化为 CRLF 后的 SHA256(兼容历史上从 Windows 检出执行的记录)。</summary>
+    public static string ComputeCrlf(byte[] content) => Sha256(ToCrlf(NormalizeToLf(content)));
+
+    /// <summary>读取文件并返回 LF 归一化 SHA256。</summary>
+    public static string ComputeCanonicalFromFile(string filePath) => ComputeCanonical(File.ReadAllBytes(filePath));
+
+    /// <summary>
+    /// 判断数据库中已记录的 hash 是否与当前文件内容等价。
+    ///
+    /// 命中 raw / LF / CRLF 三种之一即认为「SQL 内容相同,仅行尾不同」;
+    /// 三者皆不命中说明脚本内容确实被改过,调用方应拒绝启动。
+    /// </summary>
+    public static bool Matches(string? storedHash, byte[] content)
+    {
+        if (string.IsNullOrWhiteSpace(storedHash)) return false;
+
+        return Eq(storedHash, ComputeRaw(content))
+            || Eq(storedHash, ComputeCanonical(content))
+            || Eq(storedHash, ComputeCrlf(content));
+    }
+
+    /// <summary>文件版本的 <see cref="Matches(string, byte[])"/>。</summary>
+    public static bool MatchesFile(string? storedHash, string filePath)
+        => Matches(storedHash, File.ReadAllBytes(filePath));
+
+    private static bool Eq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
+
+    private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes));
+
+    /// <summary>CRLF → LF、独立 CR → LF;其余字节原样保留。</summary>
+    private static byte[] NormalizeToLf(byte[] content)
+    {
+        const byte cr = 0x0D, lf = 0x0A;
+        var output = new byte[content.Length];
+        var length = 0;
+
+        for (var i = 0; i < content.Length; i++)
+        {
+            var b = content[i];
+            if (b == cr)
+            {
+                // CRLF 只写一个 LF;孤立 CR 也转成 LF。
+                if (i + 1 < content.Length && content[i + 1] == lf) i++;
+                output[length++] = lf;
+                continue;
+            }
+
+            output[length++] = b;
+        }
+
+        return output.AsSpan(0, length).ToArray();
+    }
+
+    /// <summary>把已归一化为 LF 的内容转成 CRLF。</summary>
+    private static byte[] ToCrlf(byte[] lfContent)
+    {
+        const byte cr = 0x0D, lf = 0x0A;
+        var lfCount = 0;
+        foreach (var b in lfContent)
+            if (b == lf) lfCount++;
+
+        var output = new byte[lfContent.Length + lfCount];
+        var length = 0;
+        foreach (var b in lfContent)
+        {
+            if (b == lf) output[length++] = cr;
+            output[length++] = b;
+        }
+
+        return output;
+    }
+}

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.395</AssemblyVersion>
-    <FileVersion>1.0.395</FileVersion>
-    <Version>1.0.395</Version>
+    <AssemblyVersion>1.0.396</AssemblyVersion>
+    <FileVersion>1.0.396</FileVersion>
+    <Version>1.0.396</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 144 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Migration/MigrationScriptHashTests.cs

@@ -0,0 +1,144 @@
+using System.Security.Cryptography;
+using System.Text;
+using Admin.NET.Core.Update;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
+
+/// <summary>
+/// 迁移脚本 hash 跨平台行尾兼容契约。
+///
+/// 核心不变量:
+///   LINE ENDING DIFFERENCE = tolerated
+///   SQL CONTENT DIFFERENCE = blocked
+///
+/// 即:同一份 SQL 在 Windows(CRLF) 与 Linux(LF) 检出得到的 hash 必须互相认可,
+/// 但只要 SQL 字符内容有任何变化(改 token、增删语句、改空格)就必须判定为不一致。
+/// </summary>
+public class MigrationScriptHashTests
+{
+    private const string SqlLf =
+        "CREATE TABLE A (\nid BIGINT NOT NULL\n);\n";
+
+    private static byte[] Lf(string s) => Encoding.UTF8.GetBytes(s.Replace("\r\n", "\n"));
+
+    private static byte[] Crlf(string s) => Encoding.UTF8.GetBytes(s.Replace("\r\n", "\n").Replace("\n", "\r\n"));
+
+    private static string RawSha(byte[] b) => Convert.ToHexString(SHA256.HashData(b));
+
+    // TEST 1 — 文件 LF,库里存的是该 LF 文件的原始 hash → 必须通过
+    [Fact]
+    public void Test1_LfFile_StoredLfRawHash_Matches()
+    {
+        var file = Lf(SqlLf);
+        Assert.True(MigrationScriptHash.Matches(RawSha(file), file));
+    }
+
+    // TEST 2 — 文件 LF,库里存的是等价 CRLF 版本的 hash → 必须通过
+    [Fact]
+    public void Test2_LfFile_StoredCrlfEquivalentHash_Matches()
+    {
+        var file = Lf(SqlLf);
+        var storedFromWindows = RawSha(Crlf(SqlLf));
+
+        Assert.NotEqual(RawSha(file), storedFromWindows); // 前提:两者 hash 确实不同
+        Assert.True(MigrationScriptHash.Matches(storedFromWindows, file));
+    }
+
+    // TEST 3 — 文件 CRLF,库里存的是等价 LF 版本的 hash → 必须通过
+    [Fact]
+    public void Test3_CrlfFile_StoredLfEquivalentHash_Matches()
+    {
+        var file = Crlf(SqlLf);
+        var storedFromLinux = RawSha(Lf(SqlLf));
+
+        Assert.NotEqual(RawSha(file), storedFromLinux);
+        Assert.True(MigrationScriptHash.Matches(storedFromLinux, file));
+    }
+
+    // TEST 4 — 文件 CRLF,库里存的就是该 CRLF 文件的 hash → 必须通过
+    [Fact]
+    public void Test4_CrlfFile_StoredCrlfHash_Matches()
+    {
+        var file = Crlf(SqlLf);
+        Assert.True(MigrationScriptHash.Matches(RawSha(file), file));
+    }
+
+    // TEST 5 — SQL 被真实改写(CREATE TABLE A → B)→ 必须拒绝
+    [Fact]
+    public void Test5_RealSqlMutation_Rejected()
+    {
+        var stored = RawSha(Lf(SqlLf));
+        var mutated = Lf(SqlLf.Replace("CREATE TABLE A", "CREATE TABLE B"));
+
+        Assert.False(MigrationScriptHash.Matches(stored, mutated));
+    }
+
+    // TEST 6 — 增删 SQL token → 必须拒绝
+    [Fact]
+    public void Test6_TokenAddedOrRemoved_Rejected()
+    {
+        var stored = RawSha(Lf(SqlLf));
+
+        var added = Lf(SqlLf.Replace("id BIGINT NOT NULL", "id BIGINT NOT NULL, name VARCHAR(10)"));
+        Assert.False(MigrationScriptHash.Matches(stored, added));
+
+        var removed = Lf(SqlLf.Replace(" NOT NULL", string.Empty));
+        Assert.False(MigrationScriptHash.Matches(stored, removed));
+    }
+
+    // TEST 7 — 只改变换行(含孤立 CR 的 Mac 老式行尾)→ 必须通过
+    [Fact]
+    public void Test7_OnlyLineEndingChanged_Matches()
+    {
+        var stored = RawSha(Lf(SqlLf));
+
+        Assert.True(MigrationScriptHash.Matches(stored, Crlf(SqlLf)));
+
+        var crOnly = Encoding.UTF8.GetBytes(SqlLf.Replace("\r\n", "\n").Replace("\n", "\r"));
+        Assert.True(MigrationScriptHash.Matches(stored, crOnly));
+    }
+
+    // TEST 8 — 新 migration 持久化的 hash 必须等于 canonical LF hash,且与平台无关
+    [Fact]
+    public void Test8_PersistedHashIsCanonicalLf_AndPlatformIndependent()
+    {
+        var lf = Lf(SqlLf);
+        var crlf = Crlf(SqlLf);
+
+        Assert.Equal(RawSha(lf), MigrationScriptHash.ComputeCanonical(lf));
+        // 同一份 SQL,无论从哪种行尾的检出执行,写库的 canonical hash 必须一致
+        Assert.Equal(MigrationScriptHash.ComputeCanonical(lf), MigrationScriptHash.ComputeCanonical(crlf));
+    }
+
+    // 空白差异不属于行尾差异,必须继续拒绝(防止 Matches 被误当成 Trim/去空白)
+    [Fact]
+    public void WhitespaceOtherThanLineEndings_StillRejected()
+    {
+        var stored = RawSha(Lf(SqlLf));
+
+        var extraSpace = Lf(SqlLf.Replace("id BIGINT", "id  BIGINT"));
+        Assert.False(MigrationScriptHash.Matches(stored, extraSpace));
+
+        var trailingSpace = Lf(SqlLf.Replace("CREATE TABLE A (", "CREATE TABLE A ( "));
+        Assert.False(MigrationScriptHash.Matches(stored, trailingSpace));
+    }
+
+    // 空/缺失 stored hash 不得被当成匹配
+    [Fact]
+    public void EmptyStoredHash_NeverMatches()
+    {
+        var file = Lf(SqlLf);
+        Assert.False(MigrationScriptHash.Matches(null, file));
+        Assert.False(MigrationScriptHash.Matches(string.Empty, file));
+        Assert.False(MigrationScriptHash.Matches("   ", file));
+    }
+
+    // 大小写不敏感(库里历史记录可能是小写十六进制)
+    [Fact]
+    public void StoredHashCaseInsensitive()
+    {
+        var file = Lf(SqlLf);
+        Assert.True(MigrationScriptHash.Matches(RawSha(file).ToLowerInvariant(), file));
+    }
+}