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"} {
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}
110
111// upper marshals with a pointer receiver, like many TextMarshalers.
112type upper string
113
114func (u *upper) UnmarshalText(b []byte) error { *u = upper(strings.ToLower(string(b))); return nil }
115func (u *upper) MarshalText() ([]byte, error) { return []byte(strings.ToUpper(string(*u))), nil }
116
117func TestMarshalPointerReceiver(t *testing.T) {
118 type cfg struct {
119 U upper `kv:"u"`
120 M map[string]upper `kv:"m.*"`
121 }
122 data, err := Marshal(&cfg{U: "hello", M: map[string]upper{"a": "x"}})
123 must(t, err)
124 eq(t, string(data), "u = HELLO\nm.a = X\n")
125}
126
127func TestQuoteRoundTrip(t *testing.T) {
128 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"} {
129 t.Run(v, func(t *testing.T) {
130 entries, _, err := ParseBytes([]byte("k = "+Quote(v)+"\n"), Options{})
131 if err != nil || len(entries) != 1 || entries[0].Value != v {
132 t.Fatalf("Quote(%q) = %q reads back as %#v, %v", v, Quote(v), entries, err)
133 }
134 })
135 }
136}
137
138type marshalConfig struct {
139 Name string `kv:"name"`
140 On bool `kv:"on"`
141 After time.Duration `kv:"after,off"`
142 List []int `kv:"list"`
143 Dir Path `kv:"dir"`
144 Sub struct {
145 N uint8 `kv:"n"`
146 } `kv:"sub"`
147 Binds map[string]string `kv:"bind.*"`
148 Outs map[string]output `kv:"output.*"`
149 Rest map[string]string `kv:"*"`
150}
151
152func TestMarshalRoundTrip(t *testing.T) {
153 dir := filepath.Join(t.TempDir(), "x")
154 in := marshalConfig{
155 Name: "a # b", On: true, List: []int{1, 2}, Dir: Path(dir),
156 Binds: map[string]string{"z": "2", "a": "1"},
157 Outs: map[string]output{"DP-1": {Scale: 1.5, HDR: true, Bright: 100}},
158 Rest: map[string]string{"extra": "x"},
159 }
160 in.Sub.N = 7
161 data, err := Marshal(&in)
162 if err != nil {
163 t.Fatal(err)
164 }
165 want := `name = "a # b"
166on = on
167after = off
168list = 1,2
169dir = ` + Quote(dir) + `
170sub.n = 7
171bind.a = 1
172bind.z = 2
173output.DP-1.scale = 1.5
174output.DP-1.hdr = on
175output.DP-1.primary = off
176output.DP-1.sdr-brightness = 100
177extra = x
178`
179 if string(data) != want {
180 t.Fatalf("Marshal =\n%s\nwant\n%s", data, want)
181 }
182 var out marshalConfig
183 if w, err := Unmarshal(data, &out, Options{Strict: true}); err != nil || len(w) > 0 {
184 t.Fatalf("Unmarshal: %v %v", w, err)
185 }
186 eq(t, out, in)
187}
188
189func TestWriteFile(t *testing.T) {
190 tests := []struct {
191 name string
192 setup func(t *testing.T, dir string) string // returns the path to write
193 check func(t *testing.T, dir, path string)
194 }{
195 {
196 name: "new file and directory",
197 setup: func(t *testing.T, dir string) string { return filepath.Join(dir, "sub", "config") },
198 check: func(t *testing.T, dir, path string) {
199 eq(t, readString(t, path), "a = 1\n")
200 if runtime.GOOS != "windows" {
201 info, _ := os.Stat(path)
202 eq(t, info.Mode().Perm(), os.FileMode(0o600))
203 }
204 },
205 },
206 {
207 name: "existing file keeps its mode",
208 setup: func(t *testing.T, dir string) string {
209 p := filepath.Join(dir, "config")
210 must(t, os.WriteFile(p, []byte("old"), 0o640))
211 return p
212 },
213 check: func(t *testing.T, dir, path string) {
214 eq(t, readString(t, path), "a = 1\n")
215 if runtime.GOOS != "windows" {
216 info, _ := os.Stat(path)
217 eq(t, info.Mode().Perm(), os.FileMode(0o640))
218 }
219 entries, _ := os.ReadDir(dir)
220 eq(t, len(entries), 1) // no temporary file left
221 },
222 },
223 {
224 name: "symlink is written through",
225 setup: func(t *testing.T, dir string) string {
226 target := filepath.Join(dir, "dotfiles", "config")
227 must(t, os.MkdirAll(filepath.Dir(target), 0o700))
228 must(t, os.WriteFile(target, []byte("old"), 0o600))
229 link := filepath.Join(dir, "config")
230 if err := os.Symlink(target, link); err != nil {
231 t.Skip("symlinks unavailable:", err)
232 }
233 return link
234 },
235 check: func(t *testing.T, dir, path string) {
236 info, err := os.Lstat(path)
237 must(t, err)
238 if info.Mode()&os.ModeSymlink == 0 {
239 t.Fatal("the link was replaced")
240 }
241 eq(t, readString(t, filepath.Join(dir, "dotfiles", "config")), "a = 1\n")
242 },
243 },
244 }
245 for _, tt := range tests {
246 t.Run(tt.name, func(t *testing.T) {
247 dir := t.TempDir()
248 path := tt.setup(t, dir)
249 must(t, WriteFile(path, []byte("a = 1\n"), 0o600))
250 tt.check(t, dir, path)
251 })
252 }
253}
254
255func TestSetFile(t *testing.T) {
256 path := filepath.Join(t.TempDir(), "config")
257 must(t, os.WriteFile(path, []byte("# keep\na = 1\n"), 0o600))
258 must(t, SetFile(path, map[string]*string{"a": ptr("2"), "b": ptr("3")}, 0o600))
259 eq(t, readString(t, path), "# keep\na = 2\nb = 3\n")
260}
261
262func readString(t *testing.T, path string) string {
263 t.Helper()
264 data, err := os.ReadFile(path)
265 must(t, err)
266 return string(data)
267}
268
269func writeTemp(t *testing.T, dir, name, content string) string {
270 t.Helper()
271 p := filepath.Join(dir, name)
272 must(t, os.WriteFile(p, []byte(content), 0o600))
273 return p
274}
275
276func must(t *testing.T, err error) {
277 t.Helper()
278 if err != nil {
279 t.Fatal(err)
280 }
281}