package i18n import ( "bytes" "encoding/json" "fmt" "io" "io/fs" "unicode/utf8" ) // DecodeJSON rejects unknown fields, duplicate keys and excessive nesting. // It never expands environment references or evaluates message text. func DecodeJSON(data []byte, dst any) error { if len(data) > 1<<20 || !utf8.Valid(data) { return fmt.Errorf("i18n: document exceeds 1 MiB or is not UTF-8") } scan := json.NewDecoder(bytes.NewReader(data)) scan.UseNumber() nodes := 0 var walk func(int) error walk = func(depth int) error { nodes++ if depth > 32 || nodes > 100000 { return fmt.Errorf("i18n: JSON nesting or node limit exceeded") } t, err := scan.Token() if err != nil { return fmt.Errorf("i18n: invalid JSON") } d, ok := t.(json.Delim) if !ok { return nil } switch d { case '{': seen := map[string]bool{} for scan.More() { k, e := scan.Token() if e != nil { return fmt.Errorf("i18n: invalid JSON object") } key, ok := k.(string) if !ok || seen[key] { return fmt.Errorf("i18n: duplicate JSON key") } seen[key] = true if err := walk(depth + 1); err != nil { return err } } case '[': for scan.More() { if err := walk(depth + 1); err != nil { return err } } default: return fmt.Errorf("i18n: invalid JSON delimiter") } _, err = scan.Token() return err } if err := walk(0); err != nil { return err } if _, err := scan.Token(); err != io.EOF { return fmt.Errorf("i18n: expected one JSON value") } d := json.NewDecoder(bytes.NewReader(data)) d.UseNumber() d.DisallowUnknownFields() if err := d.Decode(dst); err != nil { return fmt.Errorf("i18n: invalid document schema") } return nil } // LoadFS reads exactly the explicitly listed files; it never scans directories. func LoadFS(source fs.FS, paths ...string) ([]Document, error) { if source == nil || len(paths) > 1024 { return nil, fmt.Errorf("i18n: filesystem required; at most 1024 files") } docs := make([]Document, 0, len(paths)) total := 0 for _, path := range paths { if !fs.ValidPath(path) { return nil, fmt.Errorf("i18n: invalid resource path") } f, err := source.Open(path) if err != nil { return nil, fmt.Errorf("i18n: cannot open %s: %w", path, err) } info, err := f.Stat() if err != nil || !info.Mode().IsRegular() { f.Close() return nil, fmt.Errorf("i18n: resource must be a regular file: %s", path) } data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) f.Close() if err != nil { return nil, err } total += len(data) if total > 4<<20 { return nil, fmt.Errorf("i18n: resources exceed 4 MiB") } var doc Document if err := DecodeJSON(data, &doc); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } docs = append(docs, doc) } return docs, nil } // Overlay replaces whole messages, never individual branches or parameter // schemas. The returned document is detached and must still pass New validation. func Overlay(base, override Document) (Document, error) { l1, e1 := canonical(base.Locale) l2, e2 := canonical(override.Locale) if e1 != nil || e2 != nil || l1 != l2 || base.Namespace != override.Namespace || base.Version != Version || override.Version != Version { return Document{}, fmt.Errorf("i18n: overlay requires matching version, locale and namespace") } // A bounded JSON round trip also rejects cyclic Go values before cloning. data, err := json.Marshal([]Document{base, override}) if err != nil || len(data) > 1<<20 { return Document{}, fmt.Errorf("i18n: invalid or oversized overlay") } var docs []Document if err := DecodeJSON(data, &docs); err != nil { return Document{}, err } out := docs[0] out.Locale = l1 if out.Messages == nil { out.Messages = map[string]Message{} } for key, m := range docs[1].Messages { out.Messages[key] = m } return out, nil }