| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- // DDL 指纹锁单测(AC-17 验收⑥的前置门禁)——设计 **v0.1.3 §2.6「sha256-lf-v1 口径」**。
- //
- // 目标态 DDL 跨仓双份存在(文档 `unified/03-migration/academic-model-ddl.sql` 与代码仓 fixture
- // `internal/academic/model/testdata/academic-model-ddl.sql`)。文档不在代码仓,跨仓字节相等无法由
- // Go 测试直接判定,因此本仓这一侧做「指纹锁」:
- //
- // sha256(lf-normalize(fixture)) == testdata/academic-model-ddl.sha256 的 digest 行
- //
- // 配合 AC-17 人工/报告流程:对「文档副本」与「fixture」各跑一次 sha256(用 `tr -d '\r'` 复现同一
- // 归一)后与锁文件比对,三者一致才视为未漂移,结论写进一致性报告留证。
- //
- // 纪律(任一 DDL 变更必须**同一提交**内更新三处):
- // 1. 文档 `unified/03-migration/academic-model-ddl.sql`;
- // 2. 本仓 fixture `testdata/academic-model-ddl.sql`;
- // 3. 本仓锁文件 `testdata/academic-model-ddl.sha256`。
- //
- // ── sha256-lf-v1 口径定义(§2.6 约束①,**本文件是实现,改动须先改设计**)──
- //
- // 归一:**仅** `\r\n → \n`(一行 ReplaceAll)。
- // 单 CR、末尾换行增删、BOM、行尾空白、大小写 **一律不归一**(它们是内容差异,必须红)。
- // 摘要:sha256(归一字节) → 64 位小写 hex。
- // 自描述:锁文件允许 `#` 注释行,**必须**含 `# algo=sha256-lf-v1`;digest 为第一个非注释行。
- // 目的:将来若换口径(如 eol=lf 落地后改裸字节),不会出现"静默变红且原因不明"。
- //
- // 为什么需要 LF 归一(而非裸字节):本仓 `core.autocrlf=true`,同一内容 Windows 检出为 CRLF、
- // Linux CI 为 LF;裸字节指纹必然跨平台假红,而这种红是纯噪声。归一只放过"换行风格"这一种
- // 无意义差异,任何字符级修改仍会改变摘要(负例见 TestDDLHashAlgoContract)。
- package model
- import (
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "os"
- "strings"
- "testing"
- )
- const (
- // ddlFixturePath DDL fixture 相对路径(go test 以包目录为工作目录)。
- ddlFixturePath = "testdata/academic-model-ddl.sql"
- // ddlHashPath DDL 指纹锁文件路径。
- ddlHashPath = "testdata/academic-model-ddl.sha256"
- // ddlHashHexLen sha256 十六进制串长度。
- ddlHashHexLen = 64
- // ddlHashAlgo 口径标识(§2.6 约束①:锁文件必须自描述,换口径须同步改本常量与设计)。
- ddlHashAlgo = "sha256-lf-v1"
- // ddlHashAlgoMarker 锁文件内的口径声明行(必须是合法注释行)。
- ddlHashAlgoMarker = "# algo=" + ddlHashAlgo
- )
- // lfNormalize 按 sha256-lf-v1 口径归一:**仅** CRLF → LF。
- //
- // 入参:b []byte——文件原始字节。出参:[]byte——归一字节(其余字符一字不动)。
- //
- // 反例(必须保持原样,不得"顺手"归一):孤立 CR、末尾换行、BOM、行尾空格、大小写。
- func lfNormalize(b []byte) []byte {
- return []byte(strings.ReplaceAll(string(b), "\r\n", "\n"))
- }
- // sha256Hex 计算内容 sha256 的 64 位小写 hex 摘要。
- func sha256Hex(b []byte) string {
- sum := sha256.Sum256(b)
- return hex.EncodeToString(sum[:])
- }
- // ddlHashLock 锁文件解析结果。
- type ddlHashLock struct {
- // Algo 口径标识(必须等于 ddlHashAlgo)。
- Algo string
- // Digest 期望摘要(64 位小写 hex)。
- Digest string
- }
- // parseDDLHashLock 解析锁文件内容(纯函数,便于负例单测覆盖格式校验)。
- //
- // 入参:raw []byte——锁文件原始字节(容忍 CRLF 检出)。
- // 出参:
- // - ddlHashLock:Algo + Digest;
- // - error:缺 algo 声明 / algo 未识别 / digest 缺失或多行 / 非 64 位小写 hex。
- //
- // 格式(§2.6 约束①):
- //
- // # algo=sha256-lf-v1 ← 必须存在(注释行与顺序无关,但必须是 # 开头)
- // # 任意其它说明 ← 可选
- // <64 位小写 hex> ← 第一个非注释行,且必须唯一
- func parseDDLHashLock(raw []byte) (ddlHashLock, error) {
- var lock ddlHashLock
- digestLines := make([]string, 0, 2)
- for _, line := range strings.Split(string(lfNormalize(raw)), "\n") {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- continue
- }
- if strings.HasPrefix(trimmed, "#") {
- // 注释体去空白后再匹配(容忍 `# algo = sha256-lf-v1` 这类手写形态)
- body := strings.ReplaceAll(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")), " ", "")
- if algo, ok := strings.CutPrefix(body, "algo="); ok {
- lock.Algo = algo
- }
- continue
- }
- digestLines = append(digestLines, trimmed)
- }
- if lock.Algo != ddlHashAlgo {
- return lock, fmt.Errorf("锁文件口径声明不符:期望 %q,实得 %q(缺 `%s` 行?)",
- ddlHashAlgo, lock.Algo, ddlHashAlgoMarker)
- }
- if len(digestLines) != 1 {
- return lock, fmt.Errorf("锁文件应只含 1 个 digest 行,实得 %d 个: %v", len(digestLines), digestLines)
- }
- digest := digestLines[0]
- if len(digest) != ddlHashHexLen {
- return lock, fmt.Errorf("digest 长度 = %d,期望 %d(纯 hex,勿带文件名/模式标记)", len(digest), ddlHashHexLen)
- }
- if strings.ToLower(digest) != digest {
- return lock, fmt.Errorf("digest 必须小写 hex,实得 %q", digest)
- }
- for _, r := range digest {
- if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
- return lock, fmt.Errorf("digest 含非 hex 字符: %q", digest)
- }
- }
- lock.Digest = digest
- return lock, nil
- }
- // readDDLHashLock 读取真实锁文件(解析失败即 fail-fast)。
- func readDDLHashLock(t *testing.T) ddlHashLock {
- t.Helper()
- raw, err := os.ReadFile(ddlHashPath)
- if err != nil {
- t.Fatalf("读取 DDL 指纹锁失败(%s): %v", ddlHashPath, err)
- }
- lock, err := parseDDLHashLock(raw)
- if err != nil {
- t.Fatalf("解析 DDL 指纹锁失败(%s): %v", ddlHashPath, err)
- }
- return lock
- }
- // TestDDLFixtureHashLock fixture 内容必须与指纹锁一致(DDL 漂移的硬门禁)。
- func TestDDLFixtureHashLock(t *testing.T) {
- lock := readDDLHashLock(t)
- raw, err := os.ReadFile(ddlFixturePath)
- if err != nil {
- t.Fatalf("读取 DDL fixture 失败(%s): %v", ddlFixturePath, err)
- }
- got := sha256Hex(lfNormalize(raw))
- if got != lock.Digest {
- t.Fatalf("DDL fixture 指纹漂移:\n 实际 = %s\n 锁定 = %s(algo=%s)\n"+
- "若为有意的 DDL 变更,必须**同一提交**内同步三处:文档副本 / fixture / testdata/academic-model-ddl.sha256",
- got, lock.Digest, lock.Algo)
- }
- }
- // TestDDLFixtureHashMatchesEmbedded embed 进测试二进制的 DDL 与磁盘 fixture 内容一致
- // (防"改了 fixture 但 embed 路径/缓存不一致"这类隐蔽漂移)。
- func TestDDLFixtureHashMatchesEmbedded(t *testing.T) {
- raw, err := os.ReadFile(ddlFixturePath)
- if err != nil {
- t.Fatalf("读取 DDL fixture 失败: %v", err)
- }
- if got, want := sha256Hex(lfNormalize([]byte(ddlSQL))), sha256Hex(lfNormalize(raw)); got != want {
- t.Fatalf("go:embed 的 DDL 与磁盘 fixture 不一致:embed=%s file=%s", got, want)
- }
- }
- // TestDDLHashAlgoContract 口径契约(§2.6 约束②,**负例不得删减**):
- // 归一只放过"换行风格",任何字符级差异必须改变摘要。
- func TestDDLHashAlgoContract(t *testing.T) {
- content := []byte("CREATE TABLE `t_x` (\n `a` INT NOT NULL\n) ENGINE=InnoDB;\n")
- t.Run("LF 与 CRLF 同摘要(归一目的)", func(t *testing.T) {
- lf := content
- crlf := []byte(strings.ReplaceAll(string(content), "\n", "\r\n"))
- if got, want := sha256Hex(lfNormalize(crlf)), sha256Hex(lfNormalize(lf)); got != want {
- t.Fatalf("CRLF 检出应归一到同一摘要:crlf=%s lf=%s", got, want)
- }
- })
- negative := []struct {
- name string
- mod []byte
- }{
- {"末尾追加空格(行尾空白是内容差异)", append([]byte(string(content)+" "), '\n')},
- {"末尾追加换行(末尾换行增删是内容差异)", append([]byte(string(content)), '\n')},
- {"单字节替换(a→b)", []byte(strings.Replace(string(content), "`a`", "`b`", 1))},
- {"单字节删除", []byte(strings.Replace(string(content), "\n", "", 1))},
- {"大小写变化(INT→int)", []byte(strings.Replace(string(content), "INT", "int", 1))},
- {"BOM 前缀(BOM 不归一)", append([]byte{0xEF, 0xBB, 0xBF}, content...)},
- {"孤立 CR 插入(单 CR 不归一)", []byte(strings.Replace(string(content), "\n", "\r", 1))},
- }
- base := sha256Hex(lfNormalize(content))
- for _, tc := range negative {
- t.Run(tc.name, func(t *testing.T) {
- if got := sha256Hex(lfNormalize(tc.mod)); got == base {
- t.Fatalf("该修改必须改变摘要(口径只放过 CRLF→LF),实得与基线相同: %s", got)
- }
- })
- }
- t.Run("孤立 CR 不被归一掉", func(t *testing.T) {
- if got, want := string(lfNormalize([]byte("a\rb"))), "a\rb"; got != want {
- t.Fatalf("单 CR 不应被归一:期望 %q,实得 %q", want, got)
- }
- if got, want := string(lfNormalize([]byte("a\r\nb"))), "a\nb"; got != want {
- t.Fatalf("CRLF 应归一为 LF:期望 %q,实得 %q", want, got)
- }
- })
- }
- // TestDDLHashLockParserAccepts 锁文件格式正例(含 CRLF 检出、注释顺序变化、多注释行)。
- func TestDDLHashLockParserAccepts(t *testing.T) {
- const digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
- cases := []struct {
- name string
- raw string
- }{
- {"最简形态", "# algo=sha256-lf-v1\n" + digest + "\n"},
- {"CRLF 检出", "# algo=sha256-lf-v1\r\n" + digest + "\r\n"},
- {"多注释 + 空行", "# 说明一\n\n# algo=sha256-lf-v1\n# 说明二\n\n" + digest + "\n"},
- {"注释在 digest 之后", "# algo=sha256-lf-v1\n" + digest + "\n# 尾注\n"},
- {"无末尾换行", "# algo=sha256-lf-v1\n" + digest},
- {"algo 行带多余空白", "# algo = sha256-lf-v1 \n" + digest + "\n"},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- lock, err := parseDDLHashLock([]byte(tc.raw))
- if err != nil {
- t.Fatalf("应解析成功,实得错误: %v", err)
- }
- if lock.Algo != ddlHashAlgo || lock.Digest != digest {
- t.Fatalf("解析结果不符:algo=%q digest=%q", lock.Algo, lock.Digest)
- }
- })
- }
- }
- // TestDDLHashLockParserRejects 锁文件格式负例(防"静默变红且原因不明":缺声明必须显式报错)。
- func TestDDLHashLockParserRejects(t *testing.T) {
- const digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
- cases := []struct {
- name string
- raw string
- }{
- {"缺少 algo 声明", digest + "\n"},
- {"algo 未识别", "# algo=sha256-raw-v1\n" + digest + "\n"},
- {"无 digest", "# algo=sha256-lf-v1\n"},
- {"两个 digest 行", "# algo=sha256-lf-v1\n" + digest + "\n" + digest + "\n"},
- {"digest 长度不足", "# algo=sha256-lf-v1\n" + digest[:63] + "\n"},
- {"digest 大写", "# algo=sha256-lf-v1\n" + strings.ToUpper(digest) + "\n"},
- {"digest 含非 hex", "# algo=sha256-lf-v1\n" + digest[:63] + "z\n"},
- {"digest 带 sha256sum 模式标记", "# algo=sha256-lf-v1\n" + digest + " testdata/x.sql\n"},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- if _, err := parseDDLHashLock([]byte(tc.raw)); err == nil {
- t.Fatal("该锁文件格式应被拒绝,实得 nil error")
- }
- })
- }
- }
|