xref: /freebsd/lib/libbsdconf/bsdconf_put.c (revision 90ad63b540317ce7c349b5ebab065b40ba748c27)
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