1package kvconf
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "path/filepath"
8 "runtime"
9 "slices"
10 "strings"
11)
12
13// Set returns data with each key of values assigned, keeping every other
14// line, comment and the spacing of edited lines. An existing assignment (the
15// last one) is replaced; otherwise a commented-out `# key = ...` is enabled;
16// otherwise the key goes after the last key sharing its longest dotted
17// parent, or at the end. A nil value removes every assignment of the key.
18// Keys are applied in sorted order, so the output is deterministic. A key
19// that cannot be read back as written (empty, with '=' or a line break,
20// starting with '#') is an error and nothing is changed.
21func Set(data []byte, values map[string]*string) ([]byte, error) {
22 keys := make([]string, 0, len(values))
23 for k := range values {
24 if !validKey(k) {
25 return nil, fmt.Errorf("kvconf: invalid key %q", k)
26 }
27 keys = append(keys, k)
28 }
29 slices.Sort(keys)
30 for _, k := range keys {
31 if v := values[k]; v == nil {
32 data = removeKey(data, k)
33 } else {
34 data = setKey(data, k, *v)
35 }
36 }
37 return data, nil
38}
39
40// lineKey returns the key of an assignment line, or "" for anything else.
41func lineKey(line string) string {
42 body := strings.TrimSpace(line)
43 if body == "" || body[0] == '#' {
44 return ""
45 }
46 k, _, ok := strings.Cut(body, "=")
47 if !ok {
48 return ""
49 }
50 return strings.TrimSpace(k)
51}
52
53func removeKey(data []byte, key string) []byte {
54 lines := strings.SplitAfter(string(data), "\n")
55 out := lines[:0]
56 for _, l := range lines {
57 if lineKey(l) != key {
58 out = append(out, l)
59 }
60 }
61 return []byte(strings.Join(out, ""))
62}
63
64func setKey(data []byte, key, value string) []byte {
65 value = Quote(value)
66 lines := strings.SplitAfter(string(data), "\n")
67 for i := len(lines) - 1; i >= 0; i-- {
68 if lineKey(lines[i]) == key {
69 prefix, rest, _ := strings.Cut(lines[i], "=")
70 lines[i] = replaceValue(prefix+"=", rest, value)
71 return []byte(strings.Join(lines, ""))
72 }
73 }
74 for i := len(lines) - 1; i >= 0; i-- {
75 indent, body, ok := strings.Cut(lines[i], "#")
76 if !ok || strings.TrimSpace(indent) != "" || lineKey(body) != key {
77 continue
78 }
79 prefix, rest, _ := strings.Cut(strings.TrimLeft(body, " \t"), "=")
80 lines[i] = replaceValue(indent+prefix+"=", rest, value)
81 return []byte(strings.Join(lines, ""))
82 }
83 // output.DP-2.scale belongs beside output.DP-2.*, not merely output.*.
84 for parent := key; ; {
85 dot := strings.LastIndexByte(parent, '.')
86 if dot < 0 {
87 break
88 }
89 parent = parent[:dot]
90 index := -1
91 for i, l := range lines {
92 if k := lineKey(l); k == parent || strings.HasPrefix(k, parent+".") {
93 index = i
94 }
95 }
96 if index < 0 {
97 continue
98 }
99 eol := "\n"
100 if strings.HasSuffix(lines[index], "\r\n") {
101 eol = "\r\n"
102 }
103 if !strings.HasSuffix(lines[index], "\n") {
104 lines[index] += eol
105 }
106 lines = slices.Insert(lines, index+1, key+" = "+value+eol)
107 return []byte(strings.Join(lines, ""))
108 }
109 var b bytes.Buffer
110 b.Grow(len(data) + len(key) + len(value) + 5)
111 b.Write(data)
112 if len(data) > 0 && data[len(data)-1] != '\n' {
113 b.WriteByte('\n')
114 }
115 b.WriteString(key)
116 b.WriteString(" = ")
117 b.WriteString(value)
118 b.WriteByte('\n')
119 return b.Bytes()
120}
121
122// replaceValue keeps indentation, spacing and a trailing comment.
123func replaceValue(prefix, rest, value string) string {
124 plain := strings.TrimRight(rest, "\r\n")
125 eol := rest[len(plain):]
126 old := stripComment(strings.TrimSpace(plain), CommentSpaced)
127 start := strings.Index(plain, old)
128 if old == "" {
129 start = len(plain) - len(strings.TrimLeft(plain, " \t"))
130 if start == 0 {
131 value = " " + value
132 }
133 }
134 return prefix + plain[:start] + value + plain[start+len(old):] + eol
135}
136
137// WriteFile replaces path with data atomically: readers and watchers never
138// see a half-written file. A symlinked path is written through to its target
139// so dotfile links survive. A new file gets perm and its directory is created
140// (0700); an existing file keeps its permissions.
141func WriteFile(path string, data []byte, perm os.FileMode) error {
142 if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
143 // Replacing a dangling link would lose it: report it instead.
144 if path, err = filepath.EvalSymlinks(path); err != nil {
145 return err
146 }
147 }
148 if info, err := os.Stat(path); err == nil {
149 perm = info.Mode().Perm()
150 } else if isNotExist(err) {
151 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
152 return err
153 }
154 } else {
155 return err
156 }
157 tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
158 if err != nil {
159 return err
160 }
161 defer os.Remove(tmp.Name()) //nolint:errcheck // gone after the rename
162 if err := writeSync(tmp, data, perm); err != nil {
163 _ = tmp.Close()
164 return err
165 }
166 if err := tmp.Close(); err != nil {
167 return err
168 }
169 if err := rename(tmp.Name(), path); err != nil {
170 return err
171 }
172 return syncDir(filepath.Dir(path))
173}
174
175func writeSync(f *os.File, data []byte, perm os.FileMode) error {
176 // Restrict before writing: the content may be secret.
177 if err := f.Chmod(perm); err != nil && runtime.GOOS != "windows" {
178 return err
179 }
180 if _, err := f.Write(data); err != nil {
181 return err
182 }
183 return f.Sync()
184}
185
186// SetFile applies Set to the file at path and writes it back atomically. A
187// missing file is created with perm.
188func SetFile(path string, values map[string]*string, perm os.FileMode) error {
189 data, err := ReadFile(path, Options{})
190 if err != nil && !isNotExist(err) {
191 return err
192 }
193 if data, err = Set(data, values); err != nil {
194 return err
195 }
196 return WriteFile(path, data, perm)
197}