1package kvconf
2
3import (
4 "os"
5 "path/filepath"
6 "runtime"
7 "strings"
8 "testing"
9 "time"
10)
11
12func ptr(s string) *string { return &s }
13
14func TestSet(t *testing.T) {
15 tests := []struct {
16 name string
17 in string
18 values map[string]*string
19 want string
20 }{
21 {name: "empty file", in: "", values: map[string]*string{"a": ptr("1")}, want: "a = 1\n"},
22 {name: "no final newline", in: "x = 1", values: map[string]*string{"a": ptr("1")}, want: "x = 1\na = 1\n"},
23 {
24 name: "replace keeps spacing and comment",
25 in: "# top\n a = 1 # one\nb = 2\n",
26 values: map[string]*string{"a": ptr("10")},
27 want: "# top\n a = 10 # one\nb = 2\n",
28 },
29 {
30 name: "replace the last assignment",
31 in: "a = 1\na = 2\n",
32 values: map[string]*string{"a": ptr("3")},
33 want: "a = 1\na = 3\n",
34 },
35 {
36 name: "replace empty value",
37 in: "a =\n",
38 values: map[string]*string{"a": ptr("1")},
39 want: "a = 1\n",
40 },
41 {
42 name: "enable commented assignment",
43 in: "# a = 1\nb = 2\n",
44 values: map[string]*string{"a": ptr("5")},
45 want: "a = 5\nb = 2\n",
46 },
47 {
48 name: "insert beside the longest dotted parent",
49 in: "output.DP-1.scale = 1\noutput.DP-2.hdr = on\nother = 1\n",
50 values: map[string]*string{"output.DP-2.scale": ptr("2")},
51 want: "output.DP-1.scale = 1\noutput.DP-2.hdr = on\noutput.DP-2.scale = 2\nother = 1\n",
52 },
53 {
54 name: "insert keeps CRLF",
55 in: "a.x = 1\r\nb = 2\r\n",
56 values: map[string]*string{"a.y": ptr("3")},
57 want: "a.x = 1\r\na.y = 3\r\nb = 2\r\n",
58 },
59 {
60 name: "quote when needed",
61 in: "",
62 values: map[string]*string{"a": ptr("x # y"), "b": ptr(" pad"), "c": ptr("")},
63 want: "a = \"x # y\"\nb = \" pad\"\nc = \"\"\n",
64 },
65 {
66 name: "remove every assignment",
67 in: "a = 1\nb = 2\na = 3\n",
68 values: map[string]*string{"a": nil},
69 want: "b = 2\n",
70 },
71 }
72 for _, tt := range tests {
73 t.Run(tt.name, func(t *testing.T) {
74 got, err := Set([]byte(tt.in), tt.values)
75 must(t, err)
76 if string(got) != tt.want {
77 t.Errorf("got\n%q\nwant\n%q", got, tt.want)
78 }
79 })
80 }
81}
82
83func TestSetRejectsInjection(t *testing.T) {
84 for _, key := range []string{"", "a = b", "a\nadmin", "bind.q = y\nadmin", "# a", " a", "\ufeffa"} {
85 t.Run(key, func(t *testing.T) {
86 if _, err := Set(nil, map[string]*string{key: ptr("v")}); err == nil {
87 t.Fatalf("Set accepted key %q", key)
88 }
89 })
90 }
91}
92
93func TestMarshalRejectsInjection(t *testing.T) {
94 tests := []struct {
95 name string
96 v any
97 }{
98 {"map name with newline", &marshalConfig{Binds: map[string]string{"x = 1\nadmin": "y"}}},
99 {"map name with equals", &marshalConfig{Binds: map[string]string{"a=b": "y"}}},
100 {"catch-all key", &marshalConfig{Rest: map[string]string{"a\nb": "y"}}},
101 }
102 for _, tt := range tests {
103 t.Run(tt.name, func(t *testing.T) {
104 if _, err := Marshal(tt.v); err == nil {
105 t.Fatal("Marshal accepted an injected key")
106 }
107 })
108 }
109 t.Run("first invalid name in order", func(t *testing.T) {
110 v := &marshalConfig{Binds: map[string]string{" d": "", " b": "", " a": "", " c": ""}}
111 for range 20 { // map order is random
112 _, err := Marshal(v)
113 if err == nil || err.Error() != `kvconf: bind.: invalid map name " a"` {
114 t.Fatalf("err = %v", err)
115 }
116 }
117 })
118}
119
120// upper marshals with a pointer receiver, like many TextMarshalers.
121type upper string
122
123func (u *upper) UnmarshalText(b []byte) error { *u = upper(strings.ToLower(string(b))); return nil }
124func (u *upper) MarshalText() ([]byte, error) { return []byte(strings.ToUpper(string(*u))), nil }
125
126func TestMarshalPointerReceiver(t *testing.T) {
127 type cfg struct {
128 U upper `kv:"u"`
129 M map[string]upper `kv:"m.*"`
130 }
131 data, err := Marshal(&cfg{U: "hello", M: map[string]upper{"a": "x"}})
132 must(t, err)
133 eq(t, string(data), "u = HELLO\nm.a = X\n")
134}
135
136func TestQuoteRoundTrip(t *testing.T) {
137 for _, v := range []string{"", "plain", "#ff0000", "a # b", " lead", "trail ", `"q"`, "it's", "a\nb", `back\slash`, "x\t#y", `it's "x # y"`, "don't # x"} {
138 t.Run(v, func(t *testing.T) {
139 entries, _, err := ParseBytes([]byte("k = "+Quote(v)+"\n"), Options{})
140 if err != nil || len(entries) != 1 || entries[0].Value != v {
141 t.Fatalf("Quote(%q) = %q reads back as %#v, %v", v, Quote(v), entries, err)
142 }
143 })
144 }
145}
146
147type marshalConfig struct {
148 Name string `kv:"name"`
149 On bool `kv:"on"`
150 After time.Duration `kv:"after,off"`
151 List []int `kv:"list"`
152 Dir Path `kv:"dir"`
153 Sub struct {
154 N uint8 `kv:"n"`
155 } `kv:"sub"`
156 Binds map[string]string `kv:"bind.*"`
157 Outs map[string]output `kv:"output.*"`
158 Rest map[string]string `kv:"*"`
159}
160
161func TestMarshalRoundTrip(t *testing.T) {
162 dir := filepath.Join(t.TempDir(), "x")
163 in := marshalConfig{
164 Name: "a # b", On: true, List: []int{1, 2}, Dir: Path(dir),
165 Binds: map[string]string{"z": "2", "a": "1"},
166 Outs: map[string]output{"DP-1": {Scale: 1.5, HDR: true, Bright: 100}},
167 Rest: map[string]string{"extra": "x"},
168 }
169 in.Sub.N = 7
170 data, err := Marshal(&in)
171 if err != nil {
172 t.Fatal(err)
173 }
174 want := `name = "a # b"
175on = on
176after = off
177list = 1,2
178dir = ` + Quote(dir) + `
179sub.n = 7
180bind.a = 1
181bind.z = 2
182output.DP-1.scale = 1.5
183output.DP-1.hdr = on
184output.DP-1.primary = off
185output.DP-1.sdr-brightness = 100
186extra = x
187`
188 if string(data) != want {
189 t.Fatalf("Marshal =\n%s\nwant\n%s", data, want)
190 }
191 var out marshalConfig
192 if w, err := Unmarshal(data, &out, Options{Strict: true}); err != nil || len(w) > 0 {
193 t.Fatalf("Unmarshal: %v %v", w, err)
194 }
195 eq(t, out, in)
196}
197
198func TestWriteFile(t *testing.T) {
199 tests := []struct {
200 name string
201 setup func(t *testing.T, dir string) string // returns the path to write
202 check func(t *testing.T, dir, path string)
203 }{
204 {
205 name: "new file and directory",
206 setup: func(t *testing.T, dir string) string { return filepath.Join(dir, "sub", "config") },
207 check: func(t *testing.T, dir, path string) {
208 eq(t, readString(t, path), "a = 1\n")
209 if runtime.GOOS != "windows" {
210 info, _ := os.Stat(path)
211 eq(t, info.Mode().Perm(), os.FileMode(0o600))
212 }
213 },
214 },
215 {
216 name: "existing file keeps its mode",
217 setup: func(t *testing.T, dir string) string {
218 p := filepath.Join(dir, "config")
219 must(t, os.WriteFile(p, []byte("old"), 0o640))
220 return p
221 },
222 check: func(t *testing.T, dir, path string) {
223 eq(t, readString(t, path), "a = 1\n")
224 if runtime.GOOS != "windows" {
225 info, _ := os.Stat(path)
226 eq(t, info.Mode().Perm(), os.FileMode(0o640))
227 }
228 entries, _ := os.ReadDir(dir)
229 eq(t, len(entries), 1) // no temporary file left
230 },
231 },
232 {
233 name: "symlink is written through",
234 setup: func(t *testing.T, dir string) string {
235 target := filepath.Join(dir, "dotfiles", "config")
236 must(t, os.MkdirAll(filepath.Dir(target), 0o700))
237 must(t, os.WriteFile(target, []byte("old"), 0o600))
238 link := filepath.Join(dir, "config")
239 if err := os.Symlink(target, link); err != nil {
240 t.Skip("symlinks unavailable:", err)
241 }
242 return link
243 },
244 check: func(t *testing.T, dir, path string) {
245 info, err := os.Lstat(path)
246 must(t, err)
247 if info.Mode()&os.ModeSymlink == 0 {
248 t.Fatal("the link was replaced")
249 }
250 eq(t, readString(t, filepath.Join(dir, "dotfiles", "config")), "a = 1\n")
251 },
252 },
253 }
254 for _, tt := range tests {
255 t.Run(tt.name, func(t *testing.T) {
256 dir := t.TempDir()
257 path := tt.setup(t, dir)
258 must(t, WriteFile(path, []byte("a = 1\n"), 0o600))
259 tt.check(t, dir, path)
260 })
261 }
262}
263
264func TestSetFile(t *testing.T) {
265 path := filepath.Join(t.TempDir(), "config")
266 must(t, os.WriteFile(path, []byte("# keep\na = 1\n"), 0o600))
267 must(t, SetFile(path, map[string]*string{"a": ptr("2"), "b": ptr("3")}, 0o600))
268 eq(t, readString(t, path), "# keep\na = 2\nb = 3\n")
269}
270
271func readString(t *testing.T, path string) string {
272 t.Helper()
273 data, err := os.ReadFile(path)
274 must(t, err)
275 return string(data)
276}
277
278func writeTemp(t *testing.T, dir, name, content string) string {
279 t.Helper()
280 p := filepath.Join(dir, name)
281 must(t, os.WriteFile(p, []byte(content), 0o600))
282 return p
283}
284
285func must(t *testing.T, err error) {
286 t.Helper()
287 if err != nil {
288 t.Fatal(err)
289 }
290}