mirror of
https://github.com/fatedier/frp.git
synced 2026-04-21 08:29:10 +08:00
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:
@@ -16,6 +16,8 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -108,7 +110,21 @@ func LoadConfigureFromFile(path string, c any, strict bool) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// 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.
|
// LoadConfigure loads configuration from bytes and unmarshal into c.
|
||||||
// Now it supports json, yaml and toml format.
|
// 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
|
var tomlObj any
|
||||||
// Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
|
tomlErr := toml.Unmarshal(b, &tomlObj)
|
||||||
if err := toml.Unmarshal(b, &tomlObj); err == nil {
|
if tomlErr == nil {
|
||||||
var err error
|
var err error
|
||||||
b, err = jsonx.Marshal(&tomlObj)
|
b, err = jsonx.Marshal(&tomlObj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
|
||||||
if yaml.IsJSONBuffer(b) {
|
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
|
// Handle YAML content
|
||||||
if strict {
|
if strict {
|
||||||
// In strict mode, always use our custom handler to support YAML merge
|
// 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
|
// Non-strict mode, parse normally
|
||||||
return yaml.Unmarshal(b, c)
|
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) {
|
func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {
|
||||||
m.ProxyType = util.EmptyOr(m.ProxyType, string(v1.ProxyTypeTCP))
|
m.ProxyType = util.EmptyOr(m.ProxyType, string(v1.ProxyTypeTCP))
|
||||||
|
|
||||||
|
|||||||
@@ -495,3 +495,109 @@ serverPort: 7000
|
|||||||
require.Equal("127.0.0.1", clientCfg.ServerAddr)
|
require.Equal("127.0.0.1", clientCfg.ServerAddr)
|
||||||
require.Equal(7000, clientCfg.ServerPort)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user