Files
headscale/hscontrol/util/file.go
T
Igor Serganov d0e10f2d46 util: read .json files as HuJSON in UnmarshalByExt (#3634)
dns.extra_records_path and derp.paths files ending in .json were decoded
with encoding/json, so a comment or trailing comma in otherwise valid
JSON made headscale refuse to load the file. Policy files already accept
HuJSON regardless of extension. HuJSON is a superset of JSON, so
standardizing .json input the same way as .hujson keeps every valid JSON
file decoding exactly as before.

Add tests for comments and trailing commas in extra records, in generic
.json decoding and in ACL policies, and for comment markers inside
string values.

Fixes #3627
2026-11-02 20:35:24 +01:00

118 lines
3.1 KiB
Go

package util
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/spf13/viper"
"github.com/tailscale/hujson"
"gopkg.in/yaml.v3"
)
const (
Base8 = 8
Base10 = 10
BitSize16 = 16
BitSize32 = 32
BitSize64 = 64
PermissionFallback = 0o700
)
// ErrDirectoryPermission is returned when creating a directory fails due to permission issues.
var ErrDirectoryPermission = errors.New("creating directory failed with permission error")
// ErrInvalidFileMode is returned by [ParseFileMode].
var ErrInvalidFileMode = errors.New("not an octal file mode between 0 and 0777")
// ErrUnknownFileFormat is returned for a file whose extension names no format
// [UnmarshalByExt] reads.
var ErrUnknownFileFormat = errors.New("unknown file format, want .json, .hujson, .yaml or .yml")
// UnmarshalByExt decodes data into a T in the format name's extension picks:
// .json or .hujson, both read as HuJSON (JSON with comments and trailing
// commas), or .yaml/.yml. The extension decides, not the content: YAML parses
// JSON syntax, so sniffing cannot tell the two apart. YAML keys are the
// lowercased Go field names.
func UnmarshalByExt[T any](name string, data []byte) (T, error) {
var (
v T
err error
)
switch ext := strings.ToLower(filepath.Ext(name)); ext {
case ".json", ".hujson":
data, err = hujson.Standardize(data)
if err == nil {
err = json.Unmarshal(data, &v)
}
case ".yaml", ".yml":
err = yaml.Unmarshal(data, &v)
default:
return v, fmt.Errorf("%s: %w", name, ErrUnknownFileFormat)
}
if err != nil {
return v, fmt.Errorf("decoding %s: %w", name, err)
}
return v, nil
}
// ReadFileByExt reads the file at path and decodes it with [UnmarshalByExt].
func ReadFileByExt[T any](path string) (T, error) {
data, err := os.ReadFile(path)
if err != nil {
var zero T
return zero, err
}
return UnmarshalByExt[T](path, data)
}
func AbsolutePathFromConfigPath(path string) string {
// If a relative path is provided, prefix it with the directory where
// the config file was found.
if (path != "") && !strings.HasPrefix(path, string(os.PathSeparator)) {
dir, _ := filepath.Split(viper.ConfigFileUsed())
if dir != "" {
path = filepath.Join(dir, path)
}
}
return path
}
// ParseFileMode parses an octal permission such as "0770", "770" or "0o770".
// Bits above [fs.ModePerm] are rejected: [os.Chmod] drops them, so "17777"
// would silently become 0777.
func ParseFileMode(s string) (fs.FileMode, error) {
mode, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(s), "0o"), Base8, BitSize64)
if err != nil || mode > uint64(fs.ModePerm) {
return 0, fmt.Errorf("%w: %q", ErrInvalidFileMode, s)
}
return fs.FileMode(mode), nil
}
func EnsureDir(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) { //nolint:noinlineerr
err := os.MkdirAll(dir, PermissionFallback)
if err != nil {
if errors.Is(err, os.ErrPermission) {
return fmt.Errorf("%w: %s", ErrDirectoryPermission, dir)
}
return fmt.Errorf("creating directory %s: %w", dir, err)
}
}
return nil
}