48f3336956
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof result, the restore outcome — are written in the BOX's language at the moment they are written. A household that switches sees the previous run's note in the old language until the next run rewrites it: the operator's §16 option 1, stated rather than hidden. EndRestoreOp no longer receives a Hungarian literal from anywhere. The language switch is a globe. Two text links wrapped in the sidebar footer and asked the reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is <details>/<summary> — a menu with no script, drawn inline because the icon sprite lives only in layout.html and the visitor pages have their own shell. Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household can never inherit a language a previous visitor picked in the same browser. POST /lang is CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the language of the page the victim's own browser shows them — and safeBackPath refuses //evil.example as well as https://, because "starts with /" alone is not the test. §16 taken: a successful claim carries the cookie into the household's setting. TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the three pages that must not change. I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings lock. Fixed by resolving the language before the callback, and guarded by a test that names the file and line in a second instead of hanging for 25 minutes. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
174 lines
6.9 KiB
Go
174 lines
6.9 KiB
Go
package util
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
|
)
|
|
|
|
// ── An error that carries the KEY of the sentence it is, not only the sentence ────────────────────
|
|
//
|
|
// Localisation slice 2 release B (R-557). 179 Hungarian sentences in this product are built deep
|
|
// inside a package with `fmt.Errorf` and printed by whoever catches them — a page, a JSON answer, a
|
|
// flash. They cannot be translated where they are SHOWN, because by then they are a finished string;
|
|
// and they must not be translated where they are MADE, because that code has no request and no
|
|
// language. So the error carries its key and the display end renders it.
|
|
//
|
|
// return util.MsgErrorf(stacks.ErrRequiredField, "deploy.field_required", label)
|
|
// ...
|
|
// http.Error(w, s.errText(r, err), 400) // the household's language
|
|
// if errors.Is(err, stacks.ErrRequiredField) { … } // still true — R-553's signal is intact
|
|
//
|
|
// THREE PROPERTIES, each earned:
|
|
//
|
|
// 1. `Error()` RETURNS THE HUNGARIAN TEXT, byte for byte what `fmt.Errorf` produced. Every printer
|
|
// that has not been converted — a log line, a third-party wrapper, `%v` in a Printf — keeps
|
|
// printing exactly what it printed before. That is what makes converting 179 producers safe
|
|
// without converting all their printers in the same commit, and it is what the parity gate
|
|
// measures.
|
|
// 2. `errors.Is` STILL WORKS, for the kind AND for a wrapped cause. `Unwrap() []error` returns the
|
|
// kind first and then any error among the arguments, so `errors.Is(err, ErrRequiredField)` and
|
|
// `errors.Is(err, os.ErrNotExist)` are both answerable. The predecessor `KindErrorf` returned the
|
|
// kind alone and dropped the cause; this does not.
|
|
// 3. AN ERROR ARGUMENT IS RENDERED RECURSIVELY. `fmt.Errorf("formázás sikertelen: %w", err)` is a
|
|
// sentence wrapping a sentence. Passing the inner error as an ARGUMENT (not as pre-rendered text)
|
|
// lets `Text(lang, …)` translate the whole chain — the outer in the household's language and the
|
|
// inner too, when the inner also carries a key. An inner error with no key prints itself, which
|
|
// is the right answer for a restic or docker message that is not ours to translate.
|
|
|
|
// MsgErrorf builds an error whose message is the bundle's `key` filled with `args`.
|
|
//
|
|
// `kind` may be nil. When it is not, it is the sentinel `errors.Is` tests — the same contract
|
|
// KindErrorf established (R-553), and the reason a converted producer does not break a decision.
|
|
func MsgErrorf(kind error, key string, args ...interface{}) error {
|
|
return &msgError{kind: kind, key: key, args: args}
|
|
}
|
|
|
|
// MsgError is MsgErrorf with no kind, for a producer nothing branches on.
|
|
func MsgError(key string, args ...interface{}) error {
|
|
return &msgError{key: key, args: args}
|
|
}
|
|
|
|
type msgError struct {
|
|
kind error
|
|
key string
|
|
args []interface{}
|
|
}
|
|
|
|
// Error returns the Hungarian sentence — the default language, and the bytes the literal carried.
|
|
func (e *msgError) Error() string { return e.Text(i18n.Default) }
|
|
|
|
// Text renders the message in lang, rendering any error argument that also carries a key.
|
|
func (e *msgError) Text(lang string) string {
|
|
b, err := i18n.Shared()
|
|
if err != nil {
|
|
return e.key
|
|
}
|
|
if len(e.args) == 0 {
|
|
return b.Msg(lang, e.key)
|
|
}
|
|
out := make([]interface{}, len(e.args))
|
|
for i, a := range e.args {
|
|
if inner, ok := a.(error); ok {
|
|
out[i] = ErrText(lang, inner)
|
|
continue
|
|
}
|
|
out[i] = a
|
|
}
|
|
return b.Msgf(lang, e.key, out...)
|
|
}
|
|
|
|
// Key returns the bundle key, so a caller can name it without rendering it.
|
|
func (e *msgError) Key() string { return e.key }
|
|
|
|
// Args returns the message's parameters as strings, for a carrier that can only hold text — the
|
|
// `fa` parameters of a flash in a redirect URL.
|
|
//
|
|
// An ERROR argument is rendered in HUNGARIAN here, deliberately and with a known cost: the carrier is
|
|
// written by one request and read by another, so the writer cannot know the reader's language, and a
|
|
// nested foreign sentence (restic, docker) is not translatable anyway. The OUTER message still
|
|
// follows the reader's language, which is the sentence that carries the meaning.
|
|
func (e *msgError) Args() []string {
|
|
out := make([]string, 0, len(e.args))
|
|
for _, a := range e.args {
|
|
if inner, ok := a.(error); ok {
|
|
out = append(out, ErrText(i18n.Default, inner))
|
|
continue
|
|
}
|
|
out = append(out, fmt.Sprintf("%v", a))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Unwrap returns the kind FIRST and then every error among the arguments, so `errors.Is` answers for
|
|
// the decision signal and for the cause alike.
|
|
func (e *msgError) Unwrap() []error {
|
|
var out []error
|
|
if e.kind != nil {
|
|
out = append(out, e.kind)
|
|
}
|
|
for _, a := range e.args {
|
|
if inner, ok := a.(error); ok && inner != nil {
|
|
out = append(out, inner)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Msg is the interface a message-carrying error satisfies. Declared so a caller can test for the
|
|
// capability rather than for this concrete type — the point is "does this error know its key", not
|
|
// "was it built by this constructor".
|
|
type Msg interface {
|
|
error
|
|
Text(lang string) string
|
|
Key() string
|
|
Args() []string
|
|
}
|
|
|
|
// AsMsg reports whether err (or anything it wraps) carries a message key, and returns it.
|
|
func AsMsg(err error) (Msg, bool) {
|
|
var m Msg
|
|
if errors.As(err, &m) {
|
|
return m, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// ErrText is the one function every display end calls: the error's sentence in lang.
|
|
//
|
|
// An error that carries a key is rendered in lang. **Anything else is returned verbatim** — a restic
|
|
// message, a docker message, an ssh message, a Go stdlib error. That is not a gap: those sentences
|
|
// are not written here and are not translated here (10-localisation.md §9, the rule R-553
|
|
// established). A customer seeing restic's own English is seeing what restic said.
|
|
func ErrText(lang string, err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
if m, ok := AsMsg(err); ok {
|
|
return m.Text(lang)
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// Text renders a bundle message in a KNOWN language — for a producer that has no request.
|
|
//
|
|
// Release C (v0.254.0) uses it for the sentences a background run SAVES: the note under last night's
|
|
// backup, the last error, the proof result. Those are written at 03:00 and read days later, so there
|
|
// is no reader to ask; the operator ruling (slice 2 §16, option 1) is that they are written in the
|
|
// box's language AT WRITE TIME and shown verbatim afterwards.
|
|
//
|
|
// The consequence, stated rather than hidden: a household that switches language sees last night's
|
|
// note in the old language until the next run rewrites it. The alternative — storing a code and
|
|
// rendering live — needs a dozen new persisted fields and a legacy path for each, which is the
|
|
// R-570 shape a dozen times over.
|
|
func Text(lang, key string, args ...interface{}) string {
|
|
b, err := i18n.Shared()
|
|
if err != nil {
|
|
return key
|
|
}
|
|
if len(args) == 0 {
|
|
return b.Msg(lang, key)
|
|
}
|
|
return b.Msgf(lang, key, args...)
|
|
}
|