1package kvconf
2
3import (
4 "errors"
5 "fmt"
6 "io/fs"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11)
12
13// ReadFile reads a config file safely: it must be a regular file (a FIFO or
14// device is rejected without blocking) no larger than opts.MaxBytes. A
15// missing file returns an error wrapping fs.ErrNotExist.
16func ReadFile(path string, opts Options) ([]byte, error) {
17 f, err := openNonblocking(path)
18 if err != nil {
19 return nil, err
20 }
21 defer f.Close() //nolint:errcheck // read only
22 info, err := f.Stat()
23 if err != nil {
24 return nil, err
25 }
26 if !info.Mode().IsRegular() {
27 return nil, &os.PathError{Op: "read", Path: path, Err: ErrNotRegular}
28 }
29 data, err := readLimited(f, opts.maxBytes())
30 if errors.Is(err, ErrTooLarge) {
31 return nil, fmt.Errorf("%w: %s", ErrTooLarge, path)
32 }
33 return data, err
34}
35
36// DefaultPath returns <config dir>/<app>/<name>. The config dir is
37// $XDG_CONFIG_HOME when it is an absolute path; otherwise ~/.config on Unix,
38// ~/Library/Application Support on macOS and %AppData% on Windows.
39func DefaultPath(app, name string) (string, error) {
40 if dir := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(dir) {
41 return filepath.Join(dir, app, name), nil
42 }
43 var dir string
44 switch runtime.GOOS {
45 case "darwin", "ios", "windows", "plan9":
46 d, err := os.UserConfigDir()
47 if err != nil {
48 return "", err
49 }
50 dir = d
51 default:
52 home, err := os.UserHomeDir()
53 if err != nil {
54 return "", err
55 }
56 dir = filepath.Join(home, ".config")
57 }
58 return filepath.Join(dir, app, name), nil
59}
60
61// Path is a file system path value: it accepts an absolute path or one
62// starting with ~/ (the home directory), and stores it cleaned. Use it as a
63// field type to validate paths while decoding.
64type Path string
65
66// UnmarshalText implements encoding.TextUnmarshaler.
67func (p *Path) UnmarshalText(text []byte) error {
68 s, err := ExpandPath(string(text))
69 if err != nil {
70 return err
71 }
72 *p = Path(s)
73 return nil
74}
75
76// MarshalText implements encoding.TextMarshaler.
77func (p Path) MarshalText() ([]byte, error) { return []byte(p), nil }
78
79// ExpandPath expands a leading ~/ and requires an absolute, NUL-free path.
80func ExpandPath(s string) (string, error) {
81 if s == "" || strings.IndexByte(s, 0) >= 0 {
82 return "", ValueError("must be a non-empty path")
83 }
84 if rest, ok := strings.CutPrefix(s, "~/"); ok || s == "~" {
85 if !ok {
86 rest = ""
87 }
88 home, err := os.UserHomeDir()
89 if err != nil || !filepath.IsAbs(home) {
90 return "", ValueError("no absolute home directory for ~")
91 }
92 s = filepath.Join(home, rest)
93 }
94 if !filepath.IsAbs(s) {
95 return "", ValueError("must be an absolute path or start with ~/")
96 }
97 return filepath.Clean(s), nil
98}
99
100// ErrNotRegular is returned by ReadFile for a directory, FIFO or device.
101var ErrNotRegular = errors.New("not a regular file")
102
103// isNotExist reports a missing file, including a missing parent directory.
104func isNotExist(err error) bool { return errors.Is(err, fs.ErrNotExist) }