Files
felhom-controller/controller/internal/i18n/r603_escape_test.go
T

144 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&#39;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)
}
}
}