pkg/config: improve error messages with line number details for config parsing (#5194)

When frpc verify encounters config errors, the error messages now include
line/column information to help users locate problems:

- TOML syntax errors: report line and column from the TOML parser
  (e.g., "toml: line 4, column 11: expected character ]")
- Type mismatch errors: report the field name and approximate line number
  (e.g., "line 3: field \"proxies\": cannot unmarshal string into ...")
- File format detection: use file extension to determine format, preventing
  silent fallthrough from TOML to YAML parser which produced confusing errors

Previously, a TOML file with syntax errors would silently fall through to the
YAML parser, which would misinterpret the content and produce unhelpful errors
like "json: cannot unmarshal string into Go value of type v1.ClientConfig".

https://claude.ai/code/session_017HWLfcXS3U2hLoy4dsg8Nv

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
fatedier
2026-03-04 19:27:30 +08:00
committed by GitHub
parent fbeb6ca43a
commit 774478d071
2 changed files with 200 additions and 6 deletions

View File

@@ -16,6 +16,8 @@ package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -108,7 +110,21 @@ func LoadConfigureFromFile(path string, c any, strict bool) error {
if err != nil {
return err
}
return LoadConfigure(content, c, strict)
return LoadConfigure(content, c, strict, detectFormatFromPath(path))
}
// detectFormatFromPath returns a format hint based on the file extension.
func detectFormatFromPath(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".toml":
return "toml"
case ".yaml", ".yml":
return "yaml"
case ".json":
return "json"
default:
return ""
}
}
// parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling
@@ -155,30 +171,102 @@ func decodeJSONContent(content []byte, target any, strict bool) error {
// LoadConfigure loads configuration from bytes and unmarshal into c.
// Now it supports json, yaml and toml format.
func LoadConfigure(b []byte, c any, strict bool) error {
// An optional format hint (e.g. "toml", "yaml", "json") can be provided
// to enable better error messages with line number information.
func LoadConfigure(b []byte, c any, strict bool, formats ...string) error {
format := ""
if len(formats) > 0 {
format = formats[0]
}
originalBytes := b
var tomlObj any
// Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
if err := toml.Unmarshal(b, &tomlObj); err == nil {
tomlErr := toml.Unmarshal(b, &tomlObj)
if tomlErr == nil {
var err error
b, err = jsonx.Marshal(&tomlObj)
if err != nil {
return err
}
} else if format == "toml" {
// File is known to be TOML but has syntax errors — report with line/column info.
return formatTOMLError(tomlErr)
}
// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
if yaml.IsJSONBuffer(b) {
return decodeJSONContent(b, c, strict)
if err := decodeJSONContent(b, c, strict); err != nil {
return enhanceDecodeError(err, originalBytes)
}
return nil
}
// Handle YAML content
if strict {
// In strict mode, always use our custom handler to support YAML merge
return parseYAMLWithDotFieldsHandling(b, c)
if err := parseYAMLWithDotFieldsHandling(b, c); err != nil {
return enhanceDecodeError(err, originalBytes)
}
return nil
}
// Non-strict mode, parse normally
return yaml.Unmarshal(b, c)
}
// formatTOMLError extracts line/column information from TOML decode errors.
func formatTOMLError(err error) error {
var decErr *toml.DecodeError
if errors.As(err, &decErr) {
row, col := decErr.Position()
return fmt.Errorf("toml: line %d, column %d: %s", row, col, decErr.Error())
}
var strictErr *toml.StrictMissingError
if errors.As(err, &strictErr) {
return fmt.Errorf("toml: %s", strictErr.Error())
}
return err
}
// enhanceDecodeError tries to add field path and line number information to JSON/YAML decode errors.
func enhanceDecodeError(err error, originalContent []byte) error {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) && typeErr.Field != "" {
line := findFieldLineInContent(originalContent, typeErr.Field)
if line > 0 {
return fmt.Errorf("line %d: field \"%s\": cannot unmarshal %s into %s", line, typeErr.Field, typeErr.Value, typeErr.Type)
}
return fmt.Errorf("field \"%s\": cannot unmarshal %s into %s", typeErr.Field, typeErr.Value, typeErr.Type)
}
return err
}
// findFieldLineInContent searches the original config content for a field name
// and returns the 1-indexed line number where it appears, or 0 if not found.
func findFieldLineInContent(content []byte, fieldPath string) int {
// Use the last component of the field path (e.g. "proxies" from "proxies" or
// "protocol" from "transport.protocol").
parts := strings.Split(fieldPath, ".")
searchKey := parts[len(parts)-1]
lines := bytes.Split(content, []byte("\n"))
for i, line := range lines {
trimmed := bytes.TrimSpace(line)
// Match TOML key assignments like: key = ...
if bytes.HasPrefix(trimmed, []byte(searchKey)) {
rest := bytes.TrimSpace(trimmed[len(searchKey):])
if len(rest) > 0 && rest[0] == '=' {
return i + 1
}
}
// Match TOML table array headers like: [[proxies]]
if bytes.Contains(trimmed, []byte("[["+searchKey+"]]")) {
return i + 1
}
}
return 0
}
func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {
m.ProxyType = util.EmptyOr(m.ProxyType, string(v1.ProxyTypeTCP))

View File

@@ -495,3 +495,109 @@ serverPort: 7000
require.Equal("127.0.0.1", clientCfg.ServerAddr)
require.Equal(7000, clientCfg.ServerPort)
}
func TestTOMLSyntaxErrorWithLineNumber(t *testing.T) {
require := require.New(t)
// TOML with syntax error (unclosed table array header)
content := `serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]
name = "test"
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.Error(err)
require.Contains(err.Error(), "line")
require.Contains(err.Error(), "toml")
}
func TestTOMLTypeMismatchErrorWithFieldInfo(t *testing.T) {
require := require.New(t)
// TOML with wrong type: proxies should be a table array, not a string
content := `serverAddr = "127.0.0.1"
serverPort = 7000
proxies = "this should be a table array"
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.Error(err)
// The error should contain field info
errMsg := err.Error()
require.Contains(errMsg, "proxies")
}
func TestFindFieldLineInContent(t *testing.T) {
content := []byte(`serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "test"
type = "tcp"
remotePort = 6000
`)
tests := []struct {
fieldPath string
wantLine int
}{
{"serverAddr", 1},
{"serverPort", 2},
{"name", 5},
{"type", 6},
{"remotePort", 7},
{"nonexistent", 0},
}
for _, tt := range tests {
t.Run(tt.fieldPath, func(t *testing.T) {
got := findFieldLineInContent(content, tt.fieldPath)
require.Equal(t, tt.wantLine, got)
})
}
}
func TestFormatDetection(t *testing.T) {
tests := []struct {
path string
format string
}{
{"config.toml", "toml"},
{"config.TOML", "toml"},
{"config.yaml", "yaml"},
{"config.yml", "yaml"},
{"config.json", "json"},
{"config.ini", ""},
{"config", ""},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
require.Equal(t, tt.format, detectFormatFromPath(tt.path))
})
}
}
func TestValidTOMLStillWorks(t *testing.T) {
require := require.New(t)
// Valid TOML with format hint should work fine
content := `serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "test"
type = "tcp"
remotePort = 6000
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.NoError(err)
require.Equal("127.0.0.1", clientCfg.ServerAddr)
require.Equal(7000, clientCfg.ServerPort)
require.Len(clientCfg.Proxies, 1)
}