// 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") } }) } }