package i18n import ( "encoding/json" "go/ast" "go/parser" "go/token" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "testing" ) // R-603: html/template escapes ' " & < > in DATA. A bundle value that Go code renders into a page as // data (a flash, a note, an error) therefore never appears verbatim on the page: "The system backup's // drive…" arrives as "backup's", and a strings.Contains assertion for it fails exactly like a // missing sentence — which sends the next person to re-fix a handler that was never broken. (Template // copy is expanded textually by Expand and is NOT escaped; only Go-named values travel as data.) // // THE GATE: a Go-named value may not carry an HTML-escapable character unless it is registered below. // The registered set is the measured state on 2026-10-05; a NEW value fails here with this pointer, // so whoever writes it either rewords it or registers it AND asserts it with html.EscapeString(want). var r603Registered = map[string]bool{ "alert.endpoint_drift": true, "kept.choice.title": true, "kept.choice.fresh.desc": true, "err.backup.unit_version_mismatch": true, "note.tier2.unit_preserved": true, "err.stacks.setup_gate_failed": true, "err.kept.occupied": true, "update.refusal.no_backup": true, "update.error.journal_failed": true, "note.unit_restore_settings_only": true, "note.tier2_no_coverage": true, "note.tier2_unit_available": true, "note.tier2_unit_not_covered": true, "note.tier2_unit_confirm_base": true, "note.tier2_unit_stale_clause": true, "note.tier2_unit_stale_notice_fmt": true, "note.restore.whole_files": true, "note.restore.scratch_state": true, } var r603Escapable = regexp.MustCompile(`['"&<>]`) var r603KeyShape = regexp.MustCompile(`^[a-z][a-z0-9_]*(?:\.[a-z0-9_\-]+)+$`) // goNamedKeys returns every bundle key that appears as a string literal in non-test Go source under // the given roots — the same "named in Go" rule scripts/i18n_go_parity.py applies. func goNamedKeys(t *testing.T, bundle map[string]string, roots ...string) map[string]string { t.Helper() out := map[string]string{} fset := token.NewFileSet() for _, root := range roots { err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { return err } f, perr := parser.ParseFile(fset, p, nil, 0) if perr != nil { return perr } ast.Inspect(f, func(n ast.Node) bool { if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING { if v, uerr := strconv.Unquote(lit.Value); uerr == nil && r603KeyShape.MatchString(v) { if _, ok := bundle[v]; ok { out[v] = p } } } return true }) return nil }) if err != nil { t.Fatal(err) } } return out } func r603Load(t *testing.T, lang string) map[string]string { t.Helper() raw, err := os.ReadFile(filepath.Join("locales", lang+".json")) if err != nil { t.Fatal(err) } var generic map[string]interface{} if err := json.Unmarshal(raw, &generic); err != nil { t.Fatal(err) } out := map[string]string{} for k, v := range generic { if s, ok := v.(string); ok { out[k] = s } } return out } // r603Offenders is the gate's verdict for one bundle: Go-named values carrying an escapable character // that are not registered. func r603Offenders(bundle, named map[string]string) []string { var bad []string for k := range named { if r603Escapable.MatchString(bundle[k]) && !r603Registered[k] { bad = append(bad, k) } } sort.Strings(bad) return bad } func TestR603_GoNamedValuesDoNotHideBehindHTMLEscaping(t *testing.T) { hu, en := r603Load(t, "hu"), r603Load(t, "en") named := goNamedKeys(t, hu, filepath.Join("..", "..", "internal"), filepath.Join("..", "..", "cmd")) if len(named) < 100 { t.Fatalf("control: only %d Go-named keys found — the source walk is not reaching the code", len(named)) } // Decoy: a planted apostrophe in a Go-named value that is not registered must be caught. var some string for k := range named { if !r603Registered[k] { some = k break } } planted := map[string]string{} for k, v := range en { planted[k] = v } planted[some] = "The drive's copy" if got := r603Offenders(planted, named); len(got) != 1 || got[0] != some { t.Fatalf("control: a planted apostrophe in %s was not caught (got %v)", some, got) } for lang, b := range map[string]map[string]string{"hu": hu, "en": en} { for _, k := range r603Offenders(b, named) { t.Errorf("R-603 [%s] %s = %q (named in %s) carries a character html/template escapes in data "+ "(' \" & < >): on the page it is not these bytes, and a strings.Contains assertion for it fails "+ "like a missing sentence. Reword it (a typographic ’ is not escaped), or register it in "+ "r603Registered and assert it with html.EscapeString(want).", lang, k, b[k], named[k]) } } // A registration that no longer needs to be there is reported, so the list only shrinks. for k := range r603Registered { if _, ok := named[k]; !ok || !r603Escapable.MatchString(en[k]+hu[k]) { t.Errorf("R-603: %s is registered but no longer a Go-named value with an escapable character — remove it", k) } } }