1// Package config reads bounded transactional flat key=value configuration.
2package config
3
4import (
5 "bufio"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12 "unicode/utf8"
13
14 "github.com/bnema/neferafk/internal/ports"
15)
16
17const MaxBytes = 64 << 10
18const MaxLine = 4 << 10
19
20func DefaultPath() string {
21 base := os.Getenv("XDG_CONFIG_HOME")
22 if base == "" {
23 if home, err := os.UserHomeDir(); err == nil {
24 base = filepath.Join(home, ".config")
25 }
26 }
27 return filepath.Join(base, "neferafk", "config")
28}
29func Load(path string) (ports.Config, error) {
30 f, err := os.Open(path)
31 if err != nil {
32 return ports.Config{}, err
33 }
34 defer f.Close()
35 return Parse(f)
36}
37
38// Parse never returns partial configuration and never resolves PIN references.
39func Parse(r io.Reader) (ports.Config, error) {
40 data, err := io.ReadAll(io.LimitReader(r, MaxBytes+1))
41 if err != nil {
42 return ports.Config{}, err
43 }
44 if !utf8.Valid(data) {
45 return ports.Config{}, fmt.Errorf("config is not valid UTF-8")
46 }
47 if len(data) > MaxBytes {
48 return ports.Config{}, fmt.Errorf("config exceeds 64KiB")
49 }
50 cfg := ports.Defaults()
51 seen := map[string]bool{}
52 scanner := bufio.NewScanner(strings.NewReader(string(data)))
53 scanner.Buffer(make([]byte, MaxLine+1), MaxLine+2)
54 line := 0
55 for scanner.Scan() {
56 line++
57 text := scanner.Text()
58 if len(text) > MaxLine {
59 return ports.Config{}, fmt.Errorf("line %d exceeds 4KiB", line)
60 }
61 text = strings.TrimSpace(strings.SplitN(text, "#", 2)[0])
62 if text == "" {
63 continue
64 }
65 key, value, ok := strings.Cut(text, "=")
66 key, value = strings.TrimSpace(key), strings.TrimSpace(value)
67 if !ok || value == "" || seen[key] {
68 return ports.Config{}, fmt.Errorf("line %d: missing value or duplicate key", line)
69 }
70 seen[key] = true
71 var target *time.Duration
72 switch key {
73 case "lock.after":
74 target = &cfg.LockAfter
75 case "fade.after":
76 target = &cfg.FadeAfter
77 case "fade.duration":
78 target = &cfg.FadeDuration
79 case "screens.off-after":
80 target = &cfg.OffAfter
81 case "sleep.after":
82 target = &cfg.SleepAfter
83 case "auth.mode":
84 cfg.Auth.Mode = ports.AuthMode(value)
85 case "auth.pin-source":
86 cfg.Auth.PINSource = ports.PINSource(value)
87 case "auth.pin-env":
88 cfg.Auth.PINEnv = value
89 case "auth.pin-entry":
90 cfg.Auth.PINEntry = value
91 default:
92 return ports.Config{}, fmt.Errorf("line %d: unknown key", line)
93 }
94 if target != nil {
95 if value == "off" && key != "fade.duration" {
96 *target = 0
97 continue
98 }
99 d, e := time.ParseDuration(value)
100 if e != nil || d < 0 || d > ports.MaxDuration || d == 0 && key != "fade.duration" {
101 return ports.Config{}, fmt.Errorf("line %d: invalid duration", line)
102 }
103 *target = d
104 }
105 }
106 if err := scanner.Err(); err != nil {
107 return ports.Config{}, fmt.Errorf("config line exceeds bound")
108 }
109 if err := cfg.Validate(); err != nil {
110 return ports.Config{}, err
111 }
112 return cfg, nil
113}