ddl_hash_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. // DDL 指纹锁单测(AC-17 验收⑥的前置门禁)——设计 **v0.1.3 §2.6「sha256-lf-v1 口径」**。
  2. //
  3. // 目标态 DDL 跨仓双份存在(文档 `unified/03-migration/academic-model-ddl.sql` 与代码仓 fixture
  4. // `internal/academic/model/testdata/academic-model-ddl.sql`)。文档不在代码仓,跨仓字节相等无法由
  5. // Go 测试直接判定,因此本仓这一侧做「指纹锁」:
  6. //
  7. // sha256(lf-normalize(fixture)) == testdata/academic-model-ddl.sha256 的 digest 行
  8. //
  9. // 配合 AC-17 人工/报告流程:对「文档副本」与「fixture」各跑一次 sha256(用 `tr -d '\r'` 复现同一
  10. // 归一)后与锁文件比对,三者一致才视为未漂移,结论写进一致性报告留证。
  11. //
  12. // 纪律(任一 DDL 变更必须**同一提交**内更新三处):
  13. // 1. 文档 `unified/03-migration/academic-model-ddl.sql`;
  14. // 2. 本仓 fixture `testdata/academic-model-ddl.sql`;
  15. // 3. 本仓锁文件 `testdata/academic-model-ddl.sha256`。
  16. //
  17. // ── sha256-lf-v1 口径定义(§2.6 约束①,**本文件是实现,改动须先改设计**)──
  18. //
  19. // 归一:**仅** `\r\n → \n`(一行 ReplaceAll)。
  20. // 单 CR、末尾换行增删、BOM、行尾空白、大小写 **一律不归一**(它们是内容差异,必须红)。
  21. // 摘要:sha256(归一字节) → 64 位小写 hex。
  22. // 自描述:锁文件允许 `#` 注释行,**必须**含 `# algo=sha256-lf-v1`;digest 为第一个非注释行。
  23. // 目的:将来若换口径(如 eol=lf 落地后改裸字节),不会出现"静默变红且原因不明"。
  24. //
  25. // 为什么需要 LF 归一(而非裸字节):本仓 `core.autocrlf=true`,同一内容 Windows 检出为 CRLF、
  26. // Linux CI 为 LF;裸字节指纹必然跨平台假红,而这种红是纯噪声。归一只放过"换行风格"这一种
  27. // 无意义差异,任何字符级修改仍会改变摘要(负例见 TestDDLHashAlgoContract)。
  28. package model
  29. import (
  30. "crypto/sha256"
  31. "encoding/hex"
  32. "fmt"
  33. "os"
  34. "strings"
  35. "testing"
  36. )
  37. const (
  38. // ddlFixturePath DDL fixture 相对路径(go test 以包目录为工作目录)。
  39. ddlFixturePath = "testdata/academic-model-ddl.sql"
  40. // ddlHashPath DDL 指纹锁文件路径。
  41. ddlHashPath = "testdata/academic-model-ddl.sha256"
  42. // ddlHashHexLen sha256 十六进制串长度。
  43. ddlHashHexLen = 64
  44. // ddlHashAlgo 口径标识(§2.6 约束①:锁文件必须自描述,换口径须同步改本常量与设计)。
  45. ddlHashAlgo = "sha256-lf-v1"
  46. // ddlHashAlgoMarker 锁文件内的口径声明行(必须是合法注释行)。
  47. ddlHashAlgoMarker = "# algo=" + ddlHashAlgo
  48. )
  49. // lfNormalize 按 sha256-lf-v1 口径归一:**仅** CRLF → LF。
  50. //
  51. // 入参:b []byte——文件原始字节。出参:[]byte——归一字节(其余字符一字不动)。
  52. //
  53. // 反例(必须保持原样,不得"顺手"归一):孤立 CR、末尾换行、BOM、行尾空格、大小写。
  54. func lfNormalize(b []byte) []byte {
  55. return []byte(strings.ReplaceAll(string(b), "\r\n", "\n"))
  56. }
  57. // sha256Hex 计算内容 sha256 的 64 位小写 hex 摘要。
  58. func sha256Hex(b []byte) string {
  59. sum := sha256.Sum256(b)
  60. return hex.EncodeToString(sum[:])
  61. }
  62. // ddlHashLock 锁文件解析结果。
  63. type ddlHashLock struct {
  64. // Algo 口径标识(必须等于 ddlHashAlgo)。
  65. Algo string
  66. // Digest 期望摘要(64 位小写 hex)。
  67. Digest string
  68. }
  69. // parseDDLHashLock 解析锁文件内容(纯函数,便于负例单测覆盖格式校验)。
  70. //
  71. // 入参:raw []byte——锁文件原始字节(容忍 CRLF 检出)。
  72. // 出参:
  73. // - ddlHashLock:Algo + Digest;
  74. // - error:缺 algo 声明 / algo 未识别 / digest 缺失或多行 / 非 64 位小写 hex。
  75. //
  76. // 格式(§2.6 约束①):
  77. //
  78. // # algo=sha256-lf-v1 ← 必须存在(注释行与顺序无关,但必须是 # 开头)
  79. // # 任意其它说明 ← 可选
  80. // <64 位小写 hex> ← 第一个非注释行,且必须唯一
  81. func parseDDLHashLock(raw []byte) (ddlHashLock, error) {
  82. var lock ddlHashLock
  83. digestLines := make([]string, 0, 2)
  84. for _, line := range strings.Split(string(lfNormalize(raw)), "\n") {
  85. trimmed := strings.TrimSpace(line)
  86. if trimmed == "" {
  87. continue
  88. }
  89. if strings.HasPrefix(trimmed, "#") {
  90. // 注释体去空白后再匹配(容忍 `# algo = sha256-lf-v1` 这类手写形态)
  91. body := strings.ReplaceAll(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")), " ", "")
  92. if algo, ok := strings.CutPrefix(body, "algo="); ok {
  93. lock.Algo = algo
  94. }
  95. continue
  96. }
  97. digestLines = append(digestLines, trimmed)
  98. }
  99. if lock.Algo != ddlHashAlgo {
  100. return lock, fmt.Errorf("锁文件口径声明不符:期望 %q,实得 %q(缺 `%s` 行?)",
  101. ddlHashAlgo, lock.Algo, ddlHashAlgoMarker)
  102. }
  103. if len(digestLines) != 1 {
  104. return lock, fmt.Errorf("锁文件应只含 1 个 digest 行,实得 %d 个: %v", len(digestLines), digestLines)
  105. }
  106. digest := digestLines[0]
  107. if len(digest) != ddlHashHexLen {
  108. return lock, fmt.Errorf("digest 长度 = %d,期望 %d(纯 hex,勿带文件名/模式标记)", len(digest), ddlHashHexLen)
  109. }
  110. if strings.ToLower(digest) != digest {
  111. return lock, fmt.Errorf("digest 必须小写 hex,实得 %q", digest)
  112. }
  113. for _, r := range digest {
  114. if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
  115. return lock, fmt.Errorf("digest 含非 hex 字符: %q", digest)
  116. }
  117. }
  118. lock.Digest = digest
  119. return lock, nil
  120. }
  121. // readDDLHashLock 读取真实锁文件(解析失败即 fail-fast)。
  122. func readDDLHashLock(t *testing.T) ddlHashLock {
  123. t.Helper()
  124. raw, err := os.ReadFile(ddlHashPath)
  125. if err != nil {
  126. t.Fatalf("读取 DDL 指纹锁失败(%s): %v", ddlHashPath, err)
  127. }
  128. lock, err := parseDDLHashLock(raw)
  129. if err != nil {
  130. t.Fatalf("解析 DDL 指纹锁失败(%s): %v", ddlHashPath, err)
  131. }
  132. return lock
  133. }
  134. // TestDDLFixtureHashLock fixture 内容必须与指纹锁一致(DDL 漂移的硬门禁)。
  135. func TestDDLFixtureHashLock(t *testing.T) {
  136. lock := readDDLHashLock(t)
  137. raw, err := os.ReadFile(ddlFixturePath)
  138. if err != nil {
  139. t.Fatalf("读取 DDL fixture 失败(%s): %v", ddlFixturePath, err)
  140. }
  141. got := sha256Hex(lfNormalize(raw))
  142. if got != lock.Digest {
  143. t.Fatalf("DDL fixture 指纹漂移:\n 实际 = %s\n 锁定 = %s(algo=%s)\n"+
  144. "若为有意的 DDL 变更,必须**同一提交**内同步三处:文档副本 / fixture / testdata/academic-model-ddl.sha256",
  145. got, lock.Digest, lock.Algo)
  146. }
  147. }
  148. // TestDDLFixtureHashMatchesEmbedded embed 进测试二进制的 DDL 与磁盘 fixture 内容一致
  149. // (防"改了 fixture 但 embed 路径/缓存不一致"这类隐蔽漂移)。
  150. func TestDDLFixtureHashMatchesEmbedded(t *testing.T) {
  151. raw, err := os.ReadFile(ddlFixturePath)
  152. if err != nil {
  153. t.Fatalf("读取 DDL fixture 失败: %v", err)
  154. }
  155. if got, want := sha256Hex(lfNormalize([]byte(ddlSQL))), sha256Hex(lfNormalize(raw)); got != want {
  156. t.Fatalf("go:embed 的 DDL 与磁盘 fixture 不一致:embed=%s file=%s", got, want)
  157. }
  158. }
  159. // TestDDLHashAlgoContract 口径契约(§2.6 约束②,**负例不得删减**):
  160. // 归一只放过"换行风格",任何字符级差异必须改变摘要。
  161. func TestDDLHashAlgoContract(t *testing.T) {
  162. content := []byte("CREATE TABLE `t_x` (\n `a` INT NOT NULL\n) ENGINE=InnoDB;\n")
  163. t.Run("LF 与 CRLF 同摘要(归一目的)", func(t *testing.T) {
  164. lf := content
  165. crlf := []byte(strings.ReplaceAll(string(content), "\n", "\r\n"))
  166. if got, want := sha256Hex(lfNormalize(crlf)), sha256Hex(lfNormalize(lf)); got != want {
  167. t.Fatalf("CRLF 检出应归一到同一摘要:crlf=%s lf=%s", got, want)
  168. }
  169. })
  170. negative := []struct {
  171. name string
  172. mod []byte
  173. }{
  174. {"末尾追加空格(行尾空白是内容差异)", append([]byte(string(content)+" "), '\n')},
  175. {"末尾追加换行(末尾换行增删是内容差异)", append([]byte(string(content)), '\n')},
  176. {"单字节替换(a→b)", []byte(strings.Replace(string(content), "`a`", "`b`", 1))},
  177. {"单字节删除", []byte(strings.Replace(string(content), "\n", "", 1))},
  178. {"大小写变化(INT→int)", []byte(strings.Replace(string(content), "INT", "int", 1))},
  179. {"BOM 前缀(BOM 不归一)", append([]byte{0xEF, 0xBB, 0xBF}, content...)},
  180. {"孤立 CR 插入(单 CR 不归一)", []byte(strings.Replace(string(content), "\n", "\r", 1))},
  181. }
  182. base := sha256Hex(lfNormalize(content))
  183. for _, tc := range negative {
  184. t.Run(tc.name, func(t *testing.T) {
  185. if got := sha256Hex(lfNormalize(tc.mod)); got == base {
  186. t.Fatalf("该修改必须改变摘要(口径只放过 CRLF→LF),实得与基线相同: %s", got)
  187. }
  188. })
  189. }
  190. t.Run("孤立 CR 不被归一掉", func(t *testing.T) {
  191. if got, want := string(lfNormalize([]byte("a\rb"))), "a\rb"; got != want {
  192. t.Fatalf("单 CR 不应被归一:期望 %q,实得 %q", want, got)
  193. }
  194. if got, want := string(lfNormalize([]byte("a\r\nb"))), "a\nb"; got != want {
  195. t.Fatalf("CRLF 应归一为 LF:期望 %q,实得 %q", want, got)
  196. }
  197. })
  198. }
  199. // TestDDLHashLockParserAccepts 锁文件格式正例(含 CRLF 检出、注释顺序变化、多注释行)。
  200. func TestDDLHashLockParserAccepts(t *testing.T) {
  201. const digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  202. cases := []struct {
  203. name string
  204. raw string
  205. }{
  206. {"最简形态", "# algo=sha256-lf-v1\n" + digest + "\n"},
  207. {"CRLF 检出", "# algo=sha256-lf-v1\r\n" + digest + "\r\n"},
  208. {"多注释 + 空行", "# 说明一\n\n# algo=sha256-lf-v1\n# 说明二\n\n" + digest + "\n"},
  209. {"注释在 digest 之后", "# algo=sha256-lf-v1\n" + digest + "\n# 尾注\n"},
  210. {"无末尾换行", "# algo=sha256-lf-v1\n" + digest},
  211. {"algo 行带多余空白", "# algo = sha256-lf-v1 \n" + digest + "\n"},
  212. }
  213. for _, tc := range cases {
  214. t.Run(tc.name, func(t *testing.T) {
  215. lock, err := parseDDLHashLock([]byte(tc.raw))
  216. if err != nil {
  217. t.Fatalf("应解析成功,实得错误: %v", err)
  218. }
  219. if lock.Algo != ddlHashAlgo || lock.Digest != digest {
  220. t.Fatalf("解析结果不符:algo=%q digest=%q", lock.Algo, lock.Digest)
  221. }
  222. })
  223. }
  224. }
  225. // TestDDLHashLockParserRejects 锁文件格式负例(防"静默变红且原因不明":缺声明必须显式报错)。
  226. func TestDDLHashLockParserRejects(t *testing.T) {
  227. const digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  228. cases := []struct {
  229. name string
  230. raw string
  231. }{
  232. {"缺少 algo 声明", digest + "\n"},
  233. {"algo 未识别", "# algo=sha256-raw-v1\n" + digest + "\n"},
  234. {"无 digest", "# algo=sha256-lf-v1\n"},
  235. {"两个 digest 行", "# algo=sha256-lf-v1\n" + digest + "\n" + digest + "\n"},
  236. {"digest 长度不足", "# algo=sha256-lf-v1\n" + digest[:63] + "\n"},
  237. {"digest 大写", "# algo=sha256-lf-v1\n" + strings.ToUpper(digest) + "\n"},
  238. {"digest 含非 hex", "# algo=sha256-lf-v1\n" + digest[:63] + "z\n"},
  239. {"digest 带 sha256sum 模式标记", "# algo=sha256-lf-v1\n" + digest + " testdata/x.sql\n"},
  240. }
  241. for _, tc := range cases {
  242. t.Run(tc.name, func(t *testing.T) {
  243. if _, err := parseDDLHashLock([]byte(tc.raw)); err == nil {
  244. t.Fatal("该锁文件格式应被拒绝,实得 nil error")
  245. }
  246. })
  247. }
  248. }