1 /*
2 * Copyright (c) 2015-2026 Devin Teske <dteske@FreeBSD.org>
3 * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
4 *
5 * SPDX-License-Identifier: BSD-2-Clause
6 */
7
8 #include <sys/stat.h>
9
10 #include <ctype.h>
11 #include <errno.h>
12 #include <fcntl.h>
13 #include <limits.h>
14 #include <stdint.h>
15 #include <stdio.h>
16 #include <stdlib.h>
17 #include <string.h>
18 #include <unistd.h>
19
20 #include "bsdconf.h"
21 #include "bsdconf_internal.h"
22
23 /*
24 * Rewrite the configuration file at `path', applying the per-directive
25 * actions described by the array of config options (first argument). Each
26 * option's action is one of:
27 *
28 * BSDCONF_ACTION_SET_VALUE set the directive to option->value.str,
29 * editing it in place if present or appending
30 * it to the file if absent;
31 * BSDCONF_ACTION_REMOVE delete the directive from the file;
32 * BSDCONF_ACTION_CHECK report (via option->result) whether the
33 * current value differs from option->value,
34 * without modifying the file.
35 *
36 * Comments, blank lines, statement ordering, and the formatting of untouched
37 * statements are preserved. With BSDCONF_OPERATOR_EQUALS, a non-default
38 * option->op both matches and rewrites the statement's assignment operator
39 * (make.conf(5) `+=' et al.). For each processed option, option->result is
40 * set to a bitmask of BSDCONF_DIRECTIVE_FOUND, BSDCONF_VALUE_CHANGED,
41 * BSDCONF_DIRECTIVE_ADDED, and BSDCONF_DIRECTIVE_REMOVED, and option->line
42 * is set to the line of the first match (if found).
43 *
44 * When every option is BSDCONF_ACTION_CHECK, or every SET_VALUE/REMOVE would
45 * leave the file unchanged, the original is left untouched: no temporary is
46 * created, mtime is not bumped, and hard links are not severed. Otherwise
47 * the file is replaced atomically: output is written to a temporary file in
48 * the same directory, flushed to disk with fsync(2), given the original's
49 * mode masked to 0666 (and, if permitted, its ownership), and then renamed
50 * over it. A crash mid-transaction leaves the original untouched.
51 *
52 * Returns zero on success; otherwise returns -1 and errno should be
53 * consulted.
54 */
55 int
bsdconf_put(struct bsdconf_option options[],const char * path,uint16_t processing_options,uint16_t put_options)56 bsdconf_put(struct bsdconf_option options[], const char *path,
57 uint16_t processing_options, uint16_t put_options)
58 {
59 bool backup;
60 bool bequals;
61 bool bsemicolon;
62 bool case_sensitive;
63 bool emptyok;
64 bool nodup;
65 bool operator_equals;
66 bool quote_always;
67 bool require_equals;
68 bool strict_equals;
69 bool unquoted;
70 int dirfd = -1;
71 int fd = -1;
72 int last_ch = -1; /* last byte emitted, or -1 if none */
73 int opchange;
74 int rv = -1;
75 int saved_errno;
76 int tmpfd = -1;
77 char *buf = NULL;
78 char *slash;
79 char *val = NULL;
80 const char *sep;
81 size_t buflen = 0;
82 size_t i;
83 size_t n;
84 size_t vlen;
85 size_t wc; /* write cursor: next unemitted byte of buf */
86 uint32_t line = 1;
87 struct bsdconf_option *option;
88 struct bsdconf_stmt st;
89 struct stat sb;
90 char rpath[PATH_MAX];
91 char tpath[PATH_MAX];
92
93 /* Sanity check the arguments */
94 if (options == NULL || path == NULL) {
95 errno = EINVAL;
96 return (-1);
97 }
98
99 /* Nothing to unlink until mkstemp(3) succeeds (see cleanup) */
100 tpath[0] = '\0';
101
102 /* Decode processing options */
103 bequals = processing_options & BSDCONF_BREAK_ON_EQUALS;
104 bsemicolon = processing_options & BSDCONF_BREAK_ON_SEMICOLON;
105 case_sensitive = processing_options & BSDCONF_CASE_SENSITIVE;
106 operator_equals = processing_options & BSDCONF_OPERATOR_EQUALS;
107 require_equals = processing_options & BSDCONF_REQUIRE_EQUALS;
108 strict_equals = processing_options & BSDCONF_STRICT_EQUALS;
109
110 /* Decode put options */
111 backup = put_options & BSDCONF_PUT_BACKUP;
112 emptyok = put_options & BSDCONF_PUT_ALLOW_EMPTY;
113 nodup = put_options & BSDCONF_PUT_NO_DUPLICATES;
114 quote_always = put_options & BSDCONF_PUT_QUOTE_ALWAYS;
115 unquoted = put_options & BSDCONF_PUT_UNQUOTED;
116
117 /* Quoting directives are mutually exclusive */
118 if (unquoted && quote_always) {
119 errno = EINVAL;
120 return (-1);
121 }
122
123 /* Reset per-option results and reject empty values up front */
124 for (n = 0; options[n].directive != NULL; n++) {
125 options[n].result = 0;
126 options[n].line = 0;
127 if (!emptyok && options[n].action == BSDCONF_ACTION_SET_VALUE &&
128 bsdconf_value_empty(&options[n])) {
129 errno = EINVAL;
130 return (-1);
131 }
132 }
133
134 /* Resolve the file path */
135 if (realpath(path, rpath) == NULL)
136 return (-1);
137
138 /*
139 * Open the original for reading. Only a regular file can be
140 * atomically replaced, so anything else is rejected below;
141 * O_NONBLOCK makes that check reachable (it is a no-op for regular
142 * files, but without it opening a fifo blocks awaiting a writer).
143 */
144 if ((fd = open(rpath, O_RDONLY | O_NONBLOCK)) < 0)
145 return (-1);
146 if (fstat(fd, &sb) != 0)
147 goto cleanup;
148 if (!S_ISREG(sb.st_mode)) {
149 errno = EINVAL;
150 goto cleanup;
151 }
152
153 /* Slurp the original into memory */
154 if ((buf = bsdconf_readfile(fd, (size_t)sb.st_size, &buflen)) == NULL)
155 goto cleanup;
156
157 /*
158 * Walk the original statement by statement. The replacement temporary
159 * is created lazily on the first real edit (see bsdconf_ensure_tmp());
160 * CHECK and equal-value paths never open it. Bytes are emitted in
161 * order via the write cursor `wc'; a matched statement diverts
162 * around the region it edits or removes.
163 */
164 wc = 0;
165 i = 0;
166 while (bsdconf_scan(buf, buflen, &i, &line, bequals, bsemicolon,
167 strict_equals, operator_equals, &st)) {
168 /* Statements without an `=' are unwritable targets */
169 if (require_equals && !st.have_equals)
170 continue;
171
172 /* Locate the option (if any) matching this directive */
173 option = NULL;
174 for (n = 0; options[n].directive != NULL; n++) {
175 if (!bsdconf_dir_matches(buf, st.dir_start, st.dir_end,
176 options[n].directive, case_sensitive))
177 continue;
178 /*
179 * A non-zero match_line selects one physical
180 * statement (e.g. make(1) `+=' strike/edit of the
181 * last assignment containing a word).
182 */
183 if (options[n].match_line != 0 &&
184 options[n].match_line != st.line)
185 continue;
186 option = &options[n];
187 break;
188 }
189 if (option == NULL)
190 continue; /* untouched; bytes flushed later */
191
192 /*
193 * make(1)-style `+=': when match_line is unset, never rewrite
194 * an existing statement and do not mark the directive FOUND,
195 * so a fresh `name+=value' line is appended below. Existing
196 * assignments for the same name are left intact so make's
197 * cumulative semantics are preserved. With match_line set,
198 * rewrite (or remove) that specific statement instead. The
199 * caller (sysconf(8)) skips the put entirely when an
200 * identical `+=value' line is already present.
201 */
202 if (operator_equals &&
203 option->action == BSDCONF_ACTION_SET_VALUE &&
204 option->op == BSDCONF_OP_APPEND &&
205 option->match_line == 0)
206 continue;
207
208 /* Enforce the no-duplicates policy */
209 if (nodup && (option->result & BSDCONF_DIRECTIVE_FOUND) != 0) {
210 errno = EEXIST;
211 goto cleanup;
212 }
213 if ((option->result & BSDCONF_DIRECTIVE_FOUND) == 0)
214 option->line = st.line;
215 option->result |= BSDCONF_DIRECTIVE_FOUND;
216
217 if (option->action == BSDCONF_ACTION_REMOVE) {
218 size_t rm_start;
219 size_t rm_end;
220 size_t k;
221 int done = 0;
222 int first_on_line = 1;
223
224 /*
225 * Is this the first statement on its physical line?
226 * With BSDCONF_BREAK_ON_SEMICOLON an earlier
227 * statement may precede it on the same line.
228 */
229 for (k = st.line_start; k < st.dir_start; k++)
230 if (!isspace((unsigned char)buf[k])) {
231 first_on_line = 0;
232 break;
233 }
234
235 /*
236 * If a further statement follows on the same line
237 * (terminator is a semicolon with a directive after
238 * it), drop this statement and the trailing
239 * separator, leaving the neighbour intact.
240 */
241 if (bsemicolon && st.term < buflen &&
242 buf[st.term] == ';') {
243 size_t f = st.term + 1;
244
245 while (f < buflen &&
246 isspace((unsigned char)buf[f]) &&
247 buf[f] != '\n')
248 f++;
249 if (f < buflen && buf[f] != '\n' &&
250 buf[f] != '#') {
251 rm_start = st.dir_start;
252 rm_end = f;
253 done = 1;
254 }
255 }
256
257 if (!done && first_on_line) {
258 /* Alone on the line: drop the whole line */
259 rm_start = st.line_start;
260 rm_end = st.line_end;
261 } else if (!done) {
262 /*
263 * Last on a shared line: drop the preceding
264 * separator (whitespace, `;', whitespace)
265 * along with this statement, keeping the
266 * terminator.
267 */
268 size_t s = st.dir_start;
269
270 while (s > st.line_start &&
271 isspace((unsigned char)buf[s - 1]))
272 s--;
273 if (s > st.line_start && buf[s - 1] == ';')
274 s--;
275 while (s > st.line_start &&
276 isspace((unsigned char)buf[s - 1]))
277 s--;
278 rm_start = s;
279 rm_end = st.term;
280 }
281
282 /* Never rewind before already-emitted bytes */
283 if (rm_start < wc)
284 rm_start = wc;
285 if (bsdconf_ensure_tmp(&tmpfd, tpath, sizeof(tpath),
286 rpath, &sb) != 0)
287 goto cleanup;
288 if (bsdconf_emit(tmpfd, buf + wc, rm_start - wc,
289 &last_ch) != 0)
290 goto cleanup;
291 if (rm_end > wc)
292 wc = rm_end;
293 option->result |= BSDCONF_DIRECTIVE_REMOVED;
294 continue;
295 }
296
297 /* SET_VALUE and CHECK both need the formatted value */
298 free(val);
299 if ((val = bsdconf_format_value(option, unquoted,
300 quote_always, bsemicolon)) == NULL)
301 goto cleanup;
302 vlen = strlen(val);
303
304 /* Does the assignment operator need to be rewritten? */
305 opchange = operator_equals &&
306 option->op != BSDCONF_OP_DEFAULT && st.have_equals &&
307 st.op != option->op;
308
309 /* Compare against the value currently in the file */
310 n = st.val_end - st.val_start;
311 if (!opchange && vlen == n && (n == 0 ||
312 memcmp(val, buf + st.val_start, n) == 0)) {
313 /* Unchanged; nothing to do for either action */
314 continue;
315 }
316
317 if (option->action == BSDCONF_ACTION_CHECK) {
318 option->result |= BSDCONF_VALUE_CHANGED;
319 continue;
320 }
321
322 /* BSDCONF_ACTION_SET_VALUE and an edit is required */
323 if (bsdconf_ensure_tmp(&tmpfd, tpath, sizeof(tpath),
324 rpath, &sb) != 0)
325 goto cleanup;
326 if (opchange) {
327 /* Rewrite the operator token in place */
328 if (bsdconf_emit(tmpfd, buf + wc, st.op_start - wc,
329 &last_ch) != 0)
330 goto cleanup;
331 if (bsdconf_emit(tmpfd, bsdconf_op_token(option->op),
332 strlen(bsdconf_op_token(option->op)),
333 &last_ch) != 0)
334 goto cleanup;
335 wc = st.eq + 1;
336 }
337
338 if (st.have_value) {
339 /* Replace the existing value in place */
340 if (bsdconf_emit(tmpfd, buf + wc, st.val_start - wc,
341 &last_ch) != 0)
342 goto cleanup;
343 if (bsdconf_emit(tmpfd, val, vlen, &last_ch) != 0)
344 goto cleanup;
345 wc = st.val_end;
346 } else if (option->type != BSDCONF_TYPE_NONE) {
347 /* Insert a value onto a value-less directive */
348 if (bsdconf_emit(tmpfd, buf + wc, st.val_start - wc,
349 &last_ch) != 0)
350 goto cleanup;
351 /* Supply a separator unless one already precedes */
352 if (st.val_start == wc ||
353 (buf[st.val_start - 1] != '=' &&
354 !isspace((unsigned char)buf[st.val_start - 1]))) {
355 sep = st.have_equals || require_equals ||
356 bequals ? "=" : " ";
357 if (bsdconf_emit(tmpfd, sep, 1,
358 &last_ch) != 0)
359 goto cleanup;
360 }
361 if (bsdconf_emit(tmpfd, val, vlen, &last_ch) != 0)
362 goto cleanup;
363 wc = st.val_start;
364 }
365 option->result |= BSDCONF_VALUE_CHANGED;
366 }
367
368 /*
369 * Append any SET_VALUE directives that were not found, and finalize
370 * the result flags for CHECK directives that were absent. The tail of
371 * the original is flushed only when a temporary already exists or an
372 * append forces one into being.
373 */
374 for (n = 0; options[n].directive != NULL; n++) {
375 option = &options[n];
376 if ((option->result & BSDCONF_DIRECTIVE_FOUND) != 0)
377 continue;
378 if (option->action == BSDCONF_ACTION_CHECK) {
379 /* Absent means it differs from the desired value */
380 option->result |= BSDCONF_VALUE_CHANGED;
381 continue;
382 }
383 if (option->action != BSDCONF_ACTION_SET_VALUE)
384 continue; /* REMOVE of an absent directive: no-op */
385
386 if (bsdconf_ensure_tmp(&tmpfd, tpath, sizeof(tpath),
387 rpath, &sb) != 0)
388 goto cleanup;
389 /* Flush any unread prefix once before the first append */
390 if (wc < buflen) {
391 if (bsdconf_emit(tmpfd, buf + wc, buflen - wc,
392 &last_ch) != 0)
393 goto cleanup;
394 wc = buflen;
395 }
396
397 /* Ensure the emitted output ends with a newline first */
398 if (last_ch != -1 && last_ch != '\n') {
399 if (bsdconf_emit(tmpfd, "\n", 1, &last_ch) != 0)
400 goto cleanup;
401 }
402
403 if (bsdconf_emit(tmpfd, option->directive,
404 strlen(option->directive), &last_ch) != 0)
405 goto cleanup;
406 if (option->type != BSDCONF_TYPE_NONE) {
407 free(val);
408 if ((val = bsdconf_format_value(option, unquoted,
409 quote_always, bsemicolon)) == NULL)
410 goto cleanup;
411 if (operator_equals &&
412 option->op != BSDCONF_OP_DEFAULT)
413 sep = bsdconf_op_token(option->op);
414 else
415 sep = (bequals || require_equals) ? "=" : " ";
416 if (bsdconf_emit(tmpfd, sep, strlen(sep),
417 &last_ch) != 0)
418 goto cleanup;
419 if (bsdconf_emit(tmpfd, val, strlen(val),
420 &last_ch) != 0)
421 goto cleanup;
422 }
423 if (bsdconf_emit(tmpfd, "\n", 1, &last_ch) != 0)
424 goto cleanup;
425 /*
426 * Line number of the statement appended at EOF: one past
427 * the number of newlines in the original, or the next line
428 * if we have to emit a separating newline first.
429 */
430 {
431 uint32_t al = 1;
432 size_t k;
433
434 for (k = 0; k < buflen; k++)
435 if (buf[k] == '\n')
436 al++;
437 if (buflen > 0 && buf[buflen - 1] != '\n')
438 al++;
439 option->line = al;
440 }
441 option->result |=
442 BSDCONF_DIRECTIVE_ADDED | BSDCONF_VALUE_CHANGED;
443 }
444
445 /*
446 * Nothing changed: every option was CHECK or an equal-value
447 * SET_VALUE/REMOVE-miss. Leave the original file alone.
448 */
449 if (tmpfd < 0) {
450 rv = 0;
451 goto cleanup;
452 }
453
454 /* Flush any remaining unread tail of the original */
455 if (bsdconf_emit(tmpfd, buf + wc, buflen - wc, &last_ch) != 0)
456 goto cleanup;
457
458 /* Optionally back up the original before replacing it */
459 if (backup) {
460 char bpath[PATH_MAX];
461 int bfd;
462
463 if (snprintf(bpath, sizeof(bpath), "%s.bak", rpath) >=
464 (int)sizeof(bpath)) {
465 errno = ENAMETOOLONG;
466 goto cleanup;
467 }
468 /*
469 * O_NOFOLLOW: refuse to follow a planted symbolic link
470 * lest the backup clobber whatever it points at.
471 */
472 if ((bfd = open(bpath, O_WRONLY | O_CREAT | O_TRUNC |
473 O_NOFOLLOW, 0600)) < 0)
474 goto cleanup;
475 if (fchmod(bfd, sb.st_mode & 0777) != 0) {
476 saved_errno = errno;
477 close(bfd);
478 errno = saved_errno;
479 goto cleanup;
480 }
481 if (bsdconf_writeall(bfd, buf, buflen) != 0) {
482 saved_errno = errno;
483 close(bfd);
484 errno = saved_errno;
485 goto cleanup;
486 }
487 if (close(bfd) != 0)
488 goto cleanup;
489 }
490
491 /* Commit: flush to disk, then atomically replace the original */
492 if (fsync(tmpfd) != 0)
493 goto cleanup;
494 if (close(tmpfd) != 0) {
495 tmpfd = -1;
496 goto cleanup;
497 }
498 tmpfd = -1;
499 if (rename(tpath, rpath) != 0)
500 goto cleanup;
501 tpath[0] = '\0'; /* renamed away; nothing to unlink */
502
503 /* Best-effort: persist the directory entry change */
504 if ((slash = strrchr(rpath, '/')) != NULL) {
505 char dpath[PATH_MAX];
506 size_t dlen = (size_t)(slash - rpath);
507
508 if (dlen == 0)
509 dlen = 1; /* the root directory */
510 if (dlen < sizeof(dpath)) {
511 memcpy(dpath, rpath, dlen);
512 dpath[dlen] = '\0';
513 if ((dirfd = open(dpath, O_RDONLY)) >= 0) {
514 (void)fsync(dirfd);
515 close(dirfd);
516 dirfd = -1;
517 }
518 }
519 }
520
521 rv = 0;
522
523 cleanup:
524 saved_errno = errno;
525 if (fd >= 0)
526 close(fd);
527 if (tmpfd >= 0)
528 close(tmpfd);
529 if (dirfd >= 0)
530 close(dirfd);
531 if (rv != 0 && tpath[0] != '\0')
532 (void)unlink(tpath); /* discard the incomplete temporary */
533 free(buf);
534 free(val);
535 errno = saved_errno;
536 return (rv);
537 }
538