// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package conf import ( "sort" "strings" "gopkg.in/ini.v1" ) // 未知配置键检测:把「配了但不生效」的键在启动时喊出来。 // // 为什么必须做:ini 库对未知键是静默忽略的。运维写错一个字母, // 程序照常启动、照常服务,但那个配置完全没生效 —— 现场没有任何线索。 // 蜜罐保留期就踩过这个坑:写成 honeypot_retention_days(多一个 t), // 代码读 honeypot_retain_days,表现为「自动清理未启用」, // 而日志里没有任何「你的键名不认识」的提示。 // // 检测方式:把 ini 文件里每一段的键名,与代码中该段结构体的 ini tag // 逐一比对(合法键名由 legalKeysOf 从反射中得出,是单一事实来源)。 // 报不出来的风险点: // - 无 ini tag 的字段(ini 用字段名做键名)→ 保守起见不登记, // 会漏报但不会误报; // - 段名本身写错 → 整段被忽略,这由「未知段」检测覆盖(见 unknownSections)。 // unknownSection 描述一个无法识别的配置段或键。 type unknownSection struct { // Section 段名。 Section string // Keys 该段内无法识别的键名(已排序)。 Keys []string // Suggestions 键名 → 最接近的合法键名建议。 Suggestions map[string]string } // detectUnknownSections 检测配置文件中无法识别的段与键。 // // 合法段名与合法键名均由调用方通过反射传入(单一事实来源)。 func detectUnknownSections(f *ini.File, knownSections map[string]map[string]bool) []unknownSection { var out []unknownSection if f == nil { return out } for _, sec := range f.Sections() { name := sec.Name() legal, known := knownSections[name] if !known { // 未登记的段:可能是拼错,也可能是本项目不关心的段。 // 只在段内确实有键时才提示(空段无所谓)。 keys := sec.KeyStrings() if len(keys) == 0 { continue } sort.Strings(keys) out = append(out, unknownSection{Section: name, Keys: keys}) continue } var bad []string suggestions := map[string]string{} for _, key := range sec.KeyStrings() { if legal[key] { continue } bad = append(bad, key) if s := closestKey(key, legal); s != "" { suggestions[key] = s } } if len(bad) == 0 { continue } sort.Strings(bad) out = append(out, unknownSection{Section: name, Keys: bad, Suggestions: suggestions}) } return out } // closestKey 在合法键名中找出与 unknown 最接近的一个。 // // 用「公共前缀长度 + 包含关系」这种无需导入编辑距离算法的启发式: // 配置键名通常是同族词(honeypot_xxx),拼错时往往仍共享较长前缀, // 或者其中一个是另一个的子串(retain_days vs retention_days)。 func closestKey(unknown string, legal map[string]bool) string { best := "" bestScore := 0 for name := range legal { score := 0 if strings.Contains(unknown, name) || strings.Contains(name, unknown) { // 包含关系:得分按较短者的长度算,取一半折扣。 short := len(name) if len(unknown) < short { short = len(unknown) } score = short / 2 } else { score = commonPrefixLen(unknown, name) } if score > bestScore { bestScore = score best = name } } // 至少要有 6 个字符的公共部分才给建议,否则噪音大于价值。 if bestScore < 6 { return "" } return best } // commonPrefixLen 返回两个字符串的公共前缀长度。 func commonPrefixLen(a, b string) int { n := len(a) if len(b) < n { n = len(b) } i := 0 for i < n && a[i] == b[i] { i++ } return i }