Page Menu
Home
FreeBSD
Search
Configure Global Search
Log In
Files
F177108817
D61064.1793811383.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
148 KB
Referenced Files
None
Subscribers
None
D61064.1793811383.diff
View Options
diff --git a/usr.sbin/sysconf/Makefile b/usr.sbin/sysconf/Makefile
--- a/usr.sbin/sysconf/Makefile
+++ b/usr.sbin/sysconf/Makefile
@@ -6,28 +6,41 @@
sysconf-generic.8 \
sysconf-loader.8 \
sysconf-make.8 \
+ sysconf-pkg.8 \
sysconf-poudriere.8 \
sysconf-rc.8 \
sysconf-src.8 \
sysconf-sysctl.8 \
- sysconf-targets.8
+ sysconf-targets.8 \
+ sysconf-ucl.8
SRCS= sysconf.c \
sysconf-targets.c \
sysconf_edit_assign.c \
sysconf_edit_make.c \
sysconf_edit_write.c \
+ sysconf_pkg.c \
sysconf_poudriere.c \
sysconf_print.c \
sysconf_query_desc.c \
sysconf_query_sysctl.c \
sysconf_query_value.c \
sysconf_resolve.c \
- sysconf_scan.c
+ sysconf_scan.c \
+ sysconf_ucl.c \
+ sysconf_ucl_array.c \
+ sysconf_ucl_file.c \
+ sysconf_ucl_index.c \
+ sysconf_ucl_lex.c \
+ sysconf_ucl_list.c \
+ sysconf_ucl_style.c \
+ sysconf_ucl_text.c \
+ sysconf_ucl_write.c
CFLAGS+= -I${SRCTOP}/lib/libbsdconf -I${.CURDIR}
+CFLAGS+= -I${SRCTOP}/contrib/libucl/include
-LIBADD= bsdconf jail
+LIBADD= bsdconf jail ucl
HAS_TESTS=
SUBDIR.${MK_TESTS}+= tests
diff --git a/usr.sbin/sysconf/Makefile.depend b/usr.sbin/sysconf/Makefile.depend
--- a/usr.sbin/sysconf/Makefile.depend
+++ b/usr.sbin/sysconf/Makefile.depend
@@ -8,6 +8,8 @@
lib/libc \
lib/libcompiler_rt \
lib/libjail \
+ lib/libucl \
+ lib/msun \
.include <dirdeps.mk>
diff --git a/usr.sbin/sysconf/sysconf-pkg.8 b/usr.sbin/sysconf/sysconf-pkg.8
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf-pkg.8
@@ -0,0 +1,190 @@
+.\" Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+.\" Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd October 25, 2026
+.Dt SYSCONF-PKG 8
+.Os
+.Sh NAME
+.Nm "sysconf pkg"
+.Nd read and modify pkg.conf and repository files
+.Sh SYNOPSIS
+.Nm sysconf
+.Cm pkg
+.Op Fl ceFinNqvx
+.Op Fl j Ar jail | Fl R Ar dir
+.Op Fl f Ar file
+.Ar name Ns Op Ns = Ns Ar value
+.Ar ...
+.Nm sysconf
+.Cm pkg
+.Op Fl eFnNqvV
+.Op Fl j Ar jail | Fl R Ar dir
+.Op Fl f Ar file
+.Fl a
+.Nm sysconf
+.Cm pkg
+.Op Fl E
+.Op Fl j Ar jail | Fl R Ar dir
+.Fl l | L
+.Sh DESCRIPTION
+The
+.Cm pkg
+target of
+.Xr sysconf 8
+is the
+.Cm ucl
+target
+.Pq see Xr sysconf-ucl 8
+with the collection of files
+.Xr pkg 8
+reads by default.
+The syntax, the typing of values, and the rewrite are those of
+.Xr libucl 3 ,
+the library
+.Xr pkg 8
+itself links;
+this target adds only the paths and the rules below.
+.Pp
+.Xr pkg 8
+has no setter of its own for these files:
+.Xr pkg-config 8
+prints one merged value without saying which file supplied it, and
+.Xr pkg-repositories 8
+records an enable or disable override under the package database
+rather than editing a repository file.
+That override is
+.Xr pkg 8 Ns 's
+and is never written here.
+.Pp
+Files are consulted in this order:
+.Bl -enum -compact
+.It
+.Pa /etc/pkg/*.conf
+.It
+.Pa /usr/local/etc/pkg.conf
+.It
+.Pa /usr/local/etc/pkg/repos/*.conf
+.El
+.Pp
+A later assignment of the same key wins.
+A repository file is a set of repository tags, which is
+.Xr pkg.conf 5
+language for a top-level group:
+.Bd -literal -offset indent
+FreeBSD-ports: {
+ url: "pkg+https://pkg.FreeBSD.org/${ABI}/latest",
+ enabled: yes
+}
+.Ed
+.Pp
+The tag is the object name.
+Address the inner key as
+.Ql FreeBSD-ports.enabled
+.Pq and Ql FreeBSD-ports.url , and so on .
+Global options in
+.Pa pkg.conf
+have no dot
+.Pq Ql PKG_ENABLE_PLUGINS .
+Booleans read back as
+.Ql true
+or
+.Ql false
+whatever spelling the file used, and are written in the spelling the
+file already uses;
+.Ql ${ABI}
+is not expanded.
+.Pp
+Files under
+.Pa /etc/pkg
+are the base repository definitions shipped with the system.
+They are read, and
+.Fl F
+names them, but they are not modified.
+A write of a repository key whose current value comes from one of those
+files goes to
+.Pa /usr/local/etc/pkg/repos/FreeBSD.conf
+instead, which is what the comment in
+.Pa /etc/pkg/FreeBSD.conf
+asks for;
+the file is created if need be.
+A key already set in a file under
+.Pa /usr/local/etc/pkg/repos
+is rewritten in that file.
+A new global key is written to
+.Pa /usr/local/etc/pkg.conf .
+Removing a key deletes it from the local files only;
+a definition that exists solely under
+.Pa /etc/pkg
+is left in place and the removal reports it as unknown.
+A write changes only the statement concerned, in the file's own style;
+a repository override file created here takes the style of the base
+file it overrides, so the two read alike.
+See
+.Xr sysconf-ucl 8 .
+.Pp
+.Ev REPOS_DIR
+is not chased.
+The directories above are the ones
+.Xr pkg 8
+uses when that list is left at its default.
+Shared options are in
+.Xr sysconf 8 .
+Cross-file write rules are in
+.Xr sysconf-targets 8 .
+.Sh FILES
+.Bl -tag -width "/usr/local/etc/pkg/repos/FreeBSD.conf" -compact
+.It Pa /etc/pkg/*.conf
+Base repository definitions.
+Read-only to
+.Nm .
+.It Pa /usr/local/etc/pkg.conf
+Global
+.Xr pkg 8
+options.
+Default write path for a key with no dot.
+.It Pa /usr/local/etc/pkg/repos/*.conf
+Local repository overrides.
+.It Pa /usr/local/etc/pkg/repos/FreeBSD.conf
+Default write path for a repository key.
+.El
+.Sh EXAMPLES
+Read and disable the ports repository without editing the base file:
+.Pp
+.Dl sysconf pkg FreeBSD-ports.enabled
+.Dl sysconf pkg FreeBSD-ports.enabled=no
+.Pp
+The second command adds
+.Bd -literal -offset indent
+FreeBSD-ports: {
+ enabled: no
+}
+.Ed
+.Pp
+to
+.Pa /usr/local/etc/pkg/repos/FreeBSD.conf
+when that tag is not already overridden there;
+.Pa /etc/pkg/FreeBSD.conf
+is untouched.
+.Pp
+Show which file supplies the repository URL:
+.Pp
+.Dl sysconf pkg -F FreeBSD-ports.url
+.Sh SEE ALSO
+.Xr libucl 3 ,
+.Xr pkg.conf 5 ,
+.Xr pkg 8 ,
+.Xr pkg-config 8 ,
+.Xr pkg-repositories 8 ,
+.Xr sysconf 8 ,
+.Xr sysconf-targets 8 ,
+.Xr sysconf-ucl 8
+.Sh HISTORY
+The
+.Nm sysconf
+utility first appeared in
+.Fx 16.0 .
+.Sh AUTHORS
+.An Devin Teske Aq Mt dteske@FreeBSD.org
+.An Faraz Vahedi Aq Mt kfv@FreeBSD.org
diff --git a/usr.sbin/sysconf/sysconf-poudriere.8 b/usr.sbin/sysconf/sysconf-poudriere.8
--- a/usr.sbin/sysconf/sysconf-poudriere.8
+++ b/usr.sbin/sysconf/sysconf-poudriere.8
@@ -3,7 +3,7 @@
.\"
.\" SPDX-License-Identifier: BSD-2-Clause
.\"
-.Dd September 23, 2026
+.Dd October 25, 2026
.Dt SYSCONF-POUDRIERE 8
.Os
.Sh NAME
@@ -79,7 +79,11 @@
A unique prefix of the
.Cm poudriere
keyword is accepted
-.Pq Ql p .
+.Pq Ql po .
+.Ql p
+also matches
+.Cm pkg
+and is rejected as ambiguous.
A unique prefix of a sub-target is accepted the same way
.Po
.Ql m
@@ -342,12 +346,12 @@
.Sh EXAMPLES
.Dl sysconf poudriere ZPOOL=zroot
.Dl sysconf poudriere --jail 131amd64 PARALLEL_JOBS=8
-.Dl sysconf p ZPOOL
-.Dl sysconf p -- src
+.Dl sysconf po ZPOOL
+.Dl sysconf po -- src
.Dl sysconf poudriere make --jail 131amd64 CFLAGS
-.Dl sysconf p m --jail 131amd64 CFLAGS+=-O2
+.Dl sysconf po m --jail 131amd64 CFLAGS+=-O2
.Dl sysconf poudriere src --set dev WITHOUT_LLVM_BINUTILS=
-.Dl sysconf p src-env --jail 131amd64 SRC_ENV_VAR
+.Dl sysconf po src-env --jail 131amd64 SRC_ENV_VAR
.Sh SEE ALSO
.Xr poudriere 8 ,
.Xr sysconf 8 ,
diff --git a/usr.sbin/sysconf/sysconf-targets.8 b/usr.sbin/sysconf/sysconf-targets.8
--- a/usr.sbin/sysconf/sysconf-targets.8
+++ b/usr.sbin/sysconf/sysconf-targets.8
@@ -3,7 +3,7 @@
.\"
.\" SPDX-License-Identifier: BSD-2-Clause
.\"
-.Dd September 22, 2026
+.Dd October 25, 2026
.Dt SYSCONF-TARGETS 8
.Os
.Sh NAME
@@ -25,6 +25,8 @@
.Xr sysconf-src 8 ,
.Xr sysconf-rc 8 ,
.Xr sysconf-poudriere 8 ,
+.Xr sysconf-pkg 8 ,
+.Xr sysconf-ucl 8 ,
and
.Xr sysconf-generic 8 .
Shared options remain in
@@ -161,10 +163,12 @@
.Xr sysconf-generic 8 ,
.Xr sysconf-loader 8 ,
.Xr sysconf-make 8 ,
+.Xr sysconf-pkg 8 ,
.Xr sysconf-poudriere 8 ,
.Xr sysconf-rc 8 ,
.Xr sysconf-src 8 ,
-.Xr sysconf-sysctl 8
+.Xr sysconf-sysctl 8 ,
+.Xr sysconf-ucl 8
.Sh HISTORY
The
.Nm sysconf
diff --git a/usr.sbin/sysconf/sysconf-targets.c b/usr.sbin/sysconf/sysconf-targets.c
--- a/usr.sbin/sysconf/sysconf-targets.c
+++ b/usr.sbin/sysconf/sysconf-targets.c
@@ -15,16 +15,19 @@
/*
* Unified target keywords: libbsdconf formats plus pass-throughs. Exact
* match wins; otherwise a unique prefix is accepted. Keep alphabetical so
- * ambiguous-match lists read naturally (e.g. `s' -> src, sysctl).
+ * ambiguous-match lists read naturally (e.g. `s' -> src, sysctl;
+ * `p' -> pkg, poudriere).
*/
static const char *const target_keywords[] = {
"generic",
"loader",
"make",
+ "pkg",
"poudriere",
"rc",
"src",
"sysctl",
+ "ucl",
NULL
};
diff --git a/usr.sbin/sysconf/sysconf-ucl.8 b/usr.sbin/sysconf/sysconf-ucl.8
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf-ucl.8
@@ -0,0 +1,248 @@
+.\" Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+.\" Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd October 25, 2026
+.Dt SYSCONF-UCL 8
+.Os
+.Sh NAME
+.Nm "sysconf ucl"
+.Nd read and modify a UCL file
+.Sh SYNOPSIS
+.Nm sysconf
+.Cm ucl
+.Fl f Ar file
+.Op Fl ceinNqvx
+.Op Fl j Ar jail | Fl R Ar dir
+.Ar name Ns Op Ns = Ns Ar value
+.Ar ...
+.Sh DESCRIPTION
+The
+.Cm ucl
+target of
+.Xr sysconf 8
+reads and rewrites files in the Universal Configuration Language,
+the syntax
+.Xr pkg.conf 5
+documents.
+The files are parsed and emitted by
+.Xr libucl 3 ,
+the same private library
+.Xr pkg 8
+links, so what this target reads is what
+.Xr pkg 8
+reads, including any defect the two then share.
+Like
+.Cm generic ,
+the target has no default files of its own and always requires
+.Fl f .
+The
+.Cm pkg
+target is this format with the
+.Xr pkg 8
+file collection bolted on;
+see
+.Xr sysconf-pkg 8 .
+.Pp
+Each file is parsed on its own, in order, and every scalar is reported
+under its dotted path:
+the key
+.Ql enabled
+inside the object
+.Ql FreeBSD-ports
+is
+.Ql FreeBSD-ports.enabled ,
+and objects nest as
+.Ql a.b.c .
+The last file to define a path is authoritative, as for every other
+target.
+Arrays are leaves and read back as compact JSON.
+On an array,
+.Ar name Ns += Ns Ar word
+and
+.Ar name Ns -= Ns Ar word
+add and strike elements
+.Pq each word parsed as a value, so Ql 3 is a number
+rather than words of a string;
+the survivors keep their order and newcomers follow the last of them,
+one per line when the array is laid out that way.
+On a string they are the word edits of
+.Xr sysconf 8 .
+.Pp
+Under an array a path component is an index rather than a key, as
+.Xr uclcmd 1
+spells it:
+.Ar name Ns \&. Ns Ar i
+is one element, counting from zero
+.Po or from the end when
+.Ar i
+is negative, so
+.Ql -1
+is the last
+.Pc ,
+and
+.Ar name Ns \&.#
+the number of elements
+.Pq reported under the name of the array counted .
+A negative index is reported as the index it resolved to.
+Under an object the same components are keys, so
+.Ql errors.404
+reads the key
+.Ql 404 .
+Indices chain into nested arrays:
+.Ar name Ns \&. Ns Ar i Ns \&. Ns Ar j
+is an element of the array that is element
+.Ar i ,
+and
+.Ar name Ns \&. Ns Ar i Ns \&.#
+its count.
+.Ar name Ns \&. Ns Ar i Ns = Ns Ar value
+changes that element where it stands, or appends when
+.Ar i
+is the count;
+.Ar name Ns \&.#= Ns Ar value
+appends likewise, and reports the index the new element took.
+.Ar name Ns \&. Ns Ar i Ns += Ns Ar word
+and
+.Ar name Ns \&. Ns Ar i Ns -= Ns Ar word
+edit the element as they would a value of its kind,
+words on a string and elements on an array;
+.Fl x Ar name Ns \&. Ns Ar i
+strikes it and, like
+.Ql -= ,
+reports the array before and after.
+Requests in one run see one another's edits, though a path into an
+array is only read as indices once the array exists in a file.
+An index past either end reads as an unknown directive, a value that is
+not an array cannot be indexed, and the count can be neither removed
+nor edited with
+.Ql +=
+or
+.Ql -= .
+A key that itself contains
+.Ql \&.
+cannot be addressed.
+A key the library cannot take bare
+.Po such as
+.Ql *
+or
+.Ql [1m
+.Pc
+is written quoted;
+a file that leaves such a key bare
+.Po as
+.Xr uclcmd 1
+may write
+.Ql *
+.Pc
+is still read.
+Values are shown as the library renders them:
+a boolean written
+.Ql yes
+reads back
+.Ql true ,
+and
+.Ql 1kb
+reads back
+.Ql 1024 .
+.Pp
+A value given on the command line is parsed as UCL,
+so
+.Ql yes ,
+.Ql 10 ,
+.Ql "[ a, b ]" ,
+and
+.Ql "{ k = v }"
+land with the types
+.Xr pkg 8
+would read.
+A value that does not parse to exactly the text given
+.Pq a Ql # the grammar would take for a comment, for instance
+is stored as that literal string, quoted; so is one holding a
+comment, a newline, or a
+.Ql \&.
+where a statement could begin, lest it carry a second statement or a
+macro into the file.
+Missing intermediate objects are created on a write;
+a write through a key that is not an object is an error.
+.Pp
+A write changes only the bytes it must.
+The statement is located in the file and its value replaced in place,
+in the file's own style:
+the separator
+.Pq Ql \&: or Ql = ,
+the terminator
+.Pq Ql \&, or Ql \&; ,
+quoting, indentation, and even the spelling of a boolean
+.Pq a Ql yes becomes Ql no , a Ql true becomes Ql false
+are taken from what is already there.
+A value is written as typed
+.Pq Ql 1kb stays Ql 1kb ;
+a string is quoted when the file quoted it or when it could not stand bare;
+a key that cannot stand bare is quoted even when its neighbours are not.
+A new key is added after its siblings in their style;
+a new object is appended in the style of the objects already in the file.
+An array that only gains or loses elements has those elements added or
+taken out one at a time, in the style of the elements around them;
+one that is reordered is replaced whole.
+Removing a key takes its own lines, a comment trailing it on the last of
+them included, and, when that leaves the enclosing object empty, the
+object too;
+striking an array element does the same with its line.
+A file that nothing changed is not rewritten;
+one that is, is replaced atomically with its mode kept,
+and its owner and group where the caller may set them.
+A symbolic link is followed:
+the file it names is replaced, and the link stays.
+Anything other than a regular file is refused.
+.Pp
+The library has the last word.
+After an edit the result is parsed again with
+.Xr libucl 3
+and the write is refused, leaving the file untouched,
+unless the parsed contents differ from the original in exactly the
+requested key.
+Macros such as
+.Ql .include
+are not expanded, so a parse never pulls in another file,
+and no variables are defined, so
+.Ql ${ABI}
+stays literal in both directions.
+A file the library rejects, or one holding a null character,
+is an error on a read as on a write.
+A key repeated in one file is an implicit array to the library,
+which presents its first element;
+that is the value reported and the statement a write edits.
+A removal takes every repetition, so none surfaces in its place.
+.Pp
+.Xr libucl 3
+keeps no line numbers; the trails shown by
+.Fl V
+report line 0.
+Shared options are in
+.Xr sysconf 8 .
+.Sh EXAMPLES
+.Dl sysconf ucl -f /tmp/app.conf server.port
+.Dl sysconf ucl -f /tmp/app.conf server.port=8080
+.Dl sysconf ucl -f /tmp/app.conf 'server.hosts=[ a, b ]'
+.Dl sysconf ucl -f /tmp/app.conf server.hosts+=c 'server.hosts.#'
+.Dl sysconf ucl -f /tmp/app.conf 'server.hosts.#=d' server.hosts.-1
+.Dl sysconf ucl -f /tmp/app.conf -x server.hosts.0
+.Dl sysconf ucl -f /tmp/app.conf matrix.1.0=30 'matrix.1.#'
+.Sh SEE ALSO
+.Xr uclcmd 1 Pq Pa ports/textproc/uclcmd ,
+.Xr libucl 3 ,
+.Xr pkg.conf 5 ,
+.Xr sysconf 8 ,
+.Xr sysconf-generic 8 ,
+.Xr sysconf-pkg 8 ,
+.Xr sysconf-targets 8
+.Sh HISTORY
+The
+.Nm sysconf
+utility first appeared in
+.Fx 16.0 .
+.Sh AUTHORS
+.An Devin Teske Aq Mt dteske@FreeBSD.org
+.An Faraz Vahedi Aq Mt kfv@FreeBSD.org
diff --git a/usr.sbin/sysconf/sysconf.8 b/usr.sbin/sysconf/sysconf.8
--- a/usr.sbin/sysconf/sysconf.8
+++ b/usr.sbin/sysconf/sysconf.8
@@ -3,7 +3,7 @@
.\"
.\" SPDX-License-Identifier: BSD-2-Clause
.\"
-.Dd September 23, 2026
+.Dd October 25, 2026
.Dt SYSCONF 8
.Os
.Sh NAME
@@ -259,6 +259,18 @@
append operator and
.Ql -=
strikes words from the last matching assignment fragment.
+For the UCL targets
+.Po
+.Cm ucl
+and
+.Cm pkg ;
+see
+.Xr sysconf-ucl 8
+.Pc ,
+the same operators on a value that is an array add and strike elements
+rather than words, and
+.Ar name Ns \&. Ns Ar i
+reaches one element.
.Pp
Files are never modified in place:
updates are streamed to a temporary file which is flushed to stable storage
@@ -312,6 +324,27 @@
.Pa poudriere.d .
See
.Xr sysconf-poudriere 8 .
+.It Cm pkg
+.Xr pkg.conf 5
+and the repository files under
+.Pa /etc/pkg
+and
+.Pa /usr/local/etc/pkg/repos ,
+parsed with
+.Xr libucl 3
+as
+.Xr pkg 8
+does; a repository tag is addressed as
+.Ql tag.key .
+See
+.Xr sysconf-pkg 8 .
+.It Cm ucl
+UCL, via
+.Xr libucl 3 ,
+applied to a file named with
+.Fl f .
+See
+.Xr sysconf-ucl 8 .
.It Cm generic
Foreign or unknown format;
requires
@@ -330,6 +363,18 @@
.Cm src .
.Ql s
matches both and is rejected with a list of matches.
+.Ql p
+likewise matches both
+.Cm pkg
+and
+.Cm poudriere ;
+.Ql pk
+selects
+.Cm pkg
+and
+.Ql po
+selects
+.Cm poudriere .
An exact keyword always wins.
Prefix matching is case-sensitive and applies to the start of the keyword
only, so
@@ -736,6 +781,8 @@
.Xr sysconf-src 8 ,
.Xr sysconf-rc 8 ,
.Xr sysconf-poudriere 8 ,
+.Xr sysconf-pkg 8 ,
+.Xr sysconf-ucl 8 ,
and
.Xr sysconf-generic 8 .
.Sh SEE ALSO
@@ -749,11 +796,13 @@
.Xr sysconf-generic 8 ,
.Xr sysconf-loader 8 ,
.Xr sysconf-make 8 ,
+.Xr sysconf-pkg 8 ,
.Xr sysconf-poudriere 8 ,
.Xr sysconf-rc 8 ,
.Xr sysconf-src 8 ,
.Xr sysconf-sysctl 8 ,
.Xr sysconf-targets 8 ,
+.Xr sysconf-ucl 8 ,
.Xr sysctl 8 ,
.Xr sysrc 8
.Sh HISTORY
diff --git a/usr.sbin/sysconf/sysconf.c b/usr.sbin/sysconf/sysconf.c
--- a/usr.sbin/sysconf/sysconf.c
+++ b/usr.sbin/sysconf/sysconf.c
@@ -27,6 +27,10 @@
size_t write_idx; /* default write target index */
int defaults_idx = -1; /* defaults file index in conf_files
* (read-only, never listed); -1 if none */
+unsigned char *conf_ro; /* per-file read-only (pkg base repos) */
+bool pkg_target; /* pkg derivative is the active target */
+size_t pkg_conf_idx; /* /usr/local/etc/pkg.conf in conf_files */
+size_t pkg_repos_idx; /* local repos/FreeBSD.conf in conf_files */
enum bsdconf_format format = BSDCONF_FORMAT_GENERIC;
/* Extra display information */
@@ -606,6 +610,14 @@
fprintf(stderr, TGTFMT, "rc",
"pass-through: all other arguments go to sysrc(8) verbatim");
poudriere_help();
+ fprintf(stderr, TGTFMT, "pkg",
+ "pkg.conf(5) plus /etc/pkg and repos/*.conf via libucl(3);");
+ fprintf(stderr, TGTFMT, "",
+ "/etc/pkg is read-only; repository keys are overridden in");
+ fprintf(stderr, TGTFMT, "",
+ "/usr/local/etc/pkg/repos/FreeBSD.conf");
+ fprintf(stderr, TGTFMT, "ucl",
+ "UCL via libucl(3); no default files; requires -f file");
fprintf(stderr, TGTFMT, "generic",
"no default files; requires -f file");
fprintf(stderr,
diff --git a/usr.sbin/sysconf/sysconf_edit_assign.c b/usr.sbin/sysconf/sysconf_edit_assign.c
--- a/usr.sbin/sysconf/sysconf_edit_assign.c
+++ b/usr.sbin/sysconf/sysconf_edit_assign.c
@@ -33,6 +33,12 @@
req->name = arg;
req->newvalue = NULL;
+ req->rawvalue = NULL;
+ req->ucl_base = NULL;
+ req->ucl_label = NULL;
+ req->ucl_idx = NULL;
+ req->ucl_res = NULL;
+ req->ucl_nidx = 0;
req->op = BSDCONF_OP_DEFAULT;
req->listop = '\0';
req->value = NULL;
@@ -184,7 +190,8 @@
* `name-=word ...') against the effective values recorded by scan_pass(),
* rewriting each request's pending value in place so the ordinary write,
* check, and echo machinery sees a plain assignment. Make/src `name-=word'
- * strikes are handled separately by do_make_strikes(). Appending to an
+ * strikes are handled separately by do_make_strikes(), and a UCL value
+ * that is an array by ucl_list_merge(). Appending to an
* unset directive creates it; striking words from one is reported like any
* other unknown directive (there is nothing to remove) and the request is
* neutralized. Returns EXIT_SUCCESS or EXIT_FAILURE.
@@ -213,6 +220,9 @@
reqs[n].name = "";
continue;
}
+ /* A UCL array takes elements, not words */
+ if (ucl_target && ucl_list_merge(&reqs[n]))
+ continue;
reqs[n].newvalue = list_merge(reqs[n].value,
reqs[n].newvalue, reqs[n].listop);
}
diff --git a/usr.sbin/sysconf/sysconf_edit_write.c b/usr.sbin/sysconf/sysconf_edit_write.c
--- a/usr.sbin/sysconf/sysconf_edit_write.c
+++ b/usr.sbin/sysconf/sysconf_edit_write.c
@@ -70,6 +70,10 @@
struct bsdconf_option *options;
struct request **owner;
+ /* UCL files are rewritten by libucl, not bsdconf_put() */
+ if (ucl_target)
+ return (ucl_do_writes());
+
if ((options = calloc(nreqs + 1, sizeof(*options))) == NULL ||
(owner = calloc(nreqs, sizeof(*owner))) == NULL)
err(EXIT_FAILURE, NULL);
diff --git a/usr.sbin/sysconf/sysconf_pkg.c b/usr.sbin/sysconf/sysconf_pkg.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_pkg.c
@@ -0,0 +1,178 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * The pkg target: the collection of files that influence pkg(8), as a
+ * derivative of the ucl target. The syntax is libucl's (see
+ * sysconf_ucl.c); this file is policy only: the paths, the sourcing
+ * order, the read-only base repository files, and where a write lands.
+ *
+ * Sourcing order matches pkg's defaults (REPOS_DIR is not re-read):
+ * each .conf file in /etc/pkg
+ * /usr/local/etc/pkg.conf
+ * each .conf file in /usr/local/etc/pkg/repos
+ * Files under /etc/pkg are the base repository definitions. They are
+ * consulted, and -F names them, but a write is never applied there: a
+ * repository key (a dotted path, the repository tag plus the inner key)
+ * is overridden in /usr/local/etc/pkg/repos/FreeBSD.conf, and a global
+ * key is written to /usr/local/etc/pkg.conf. That is the split the
+ * comment in /etc/pkg/FreeBSD.conf asks for.
+ *
+ * pkg(8) itself offers no setter for these files: `pkg config' reads one
+ * merged value, and `pkg repositories enable' records a side override
+ * under the package database rather than editing a repository file.
+ * That switch stays pkg's own; this target never writes repos_state.
+ */
+
+#include "sysconf_priv.h"
+
+static const struct bsdconf_source pkg_sources[] = {
+ { BSDCONF_SOURCE_DIR, "/etc/pkg" },
+ { BSDCONF_SOURCE_FILE, "/usr/local/etc/pkg.conf" },
+ { BSDCONF_SOURCE_DIR, "/usr/local/etc/pkg/repos" },
+ { BSDCONF_SOURCE_FILE, NULL },
+};
+
+#define PKG_CONF_REL "/usr/local/etc/pkg.conf"
+#define PKG_REPOS_REL "/usr/local/etc/pkg/repos/FreeBSD.conf"
+#define PKG_REPOS_DIR "/usr/local/etc/pkg/repos/"
+#define PKG_BASE_DIR "/etc/pkg/"
+
+enum bsdconf_format pkg_format;
+
+/*
+ * Register pkg as a derivative of the (already registered) ucl target.
+ * Called from ucl_register(); idempotent.
+ */
+int
+pkg_register(void)
+{
+ static bool done;
+ struct bsdconf_format_def def;
+
+ if (done)
+ return (0);
+ if (bsdconf_format_derive(ucl_format, &def) != 0)
+ return (-1);
+ def.keyword = "pkg";
+ def.path = PKG_REPOS_REL;
+ def.sources = pkg_sources;
+ if (bsdconf_format_register(&def, &pkg_format) != 0)
+ return (-1);
+ done = true;
+ return (0);
+}
+
+static const char *
+rel_of(const char *path)
+{
+ size_t n;
+
+ n = strlen(rootdir);
+ if (n > 0 && strncmp(path, rootdir, n) == 0)
+ return (path + n);
+ return (path);
+}
+
+static char *
+join_root(const char *rel)
+{
+ char *path;
+ char buf[PATH_MAX];
+
+ if (snprintf(buf, sizeof(buf), "%s%s", rootdir, rel) >=
+ (int)sizeof(buf))
+ errx(EXIT_FAILURE, "%s%s: %s", rootdir, rel,
+ strerror(ENAMETOOLONG));
+ if ((path = strdup(buf)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ return (path);
+}
+
+static int
+index_of(const char *path)
+{
+ size_t n;
+
+ for (n = 0; n < nconf_files; n++) {
+ if (strcmp(conf_files[n], path) == 0)
+ return ((int)n);
+ }
+ return (-1);
+}
+
+static void
+insert_at(size_t at, char *path)
+{
+ char **tmp;
+ size_t n;
+
+ tmp = reallocarray(conf_files, nconf_files + 1, sizeof(*tmp));
+ if (tmp == NULL)
+ err(EXIT_FAILURE, NULL);
+ conf_files = tmp;
+ for (n = nconf_files; n > at; n--)
+ conf_files[n] = conf_files[n - 1];
+ conf_files[at] = path;
+ nconf_files++;
+}
+
+void
+pkg_prepare(void)
+{
+ size_t at;
+ size_t n;
+ int idx;
+ char *conf;
+ char *repos;
+ const char *rel;
+
+ if (format != pkg_format)
+ return;
+
+ conf = join_root(PKG_CONF_REL);
+ idx = index_of(conf);
+ free(conf);
+ if (idx < 0)
+ errx(EXIT_FAILURE, "pkg.conf missing from file list");
+ pkg_conf_idx = (size_t)idx;
+ write_idx = pkg_conf_idx;
+
+ repos = join_root(PKG_REPOS_REL);
+ idx = index_of(repos);
+ if (idx < 0) {
+ at = nconf_files;
+ for (n = 0; n < nconf_files; n++) {
+ rel = rel_of(conf_files[n]);
+ if (strncmp(rel, PKG_REPOS_DIR,
+ sizeof(PKG_REPOS_DIR) - 1) != 0)
+ continue;
+ if (strcmp(repos, conf_files[n]) < 0) {
+ at = n;
+ break;
+ }
+ at = n + 1;
+ }
+ insert_at(at, repos);
+ pkg_repos_idx = at;
+ if (pkg_conf_idx >= at)
+ pkg_conf_idx++;
+ write_idx = pkg_conf_idx;
+ } else {
+ free(repos);
+ pkg_repos_idx = (size_t)idx;
+ }
+
+ if ((conf_ro = calloc(nconf_files, sizeof(*conf_ro))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (n = 0; n < nconf_files; n++) {
+ rel = rel_of(conf_files[n]);
+ if (strncmp(rel, PKG_BASE_DIR, sizeof(PKG_BASE_DIR) - 1) == 0)
+ conf_ro[n] = 1;
+ }
+ pkg_target = true;
+}
diff --git a/usr.sbin/sysconf/sysconf_priv.h b/usr.sbin/sysconf/sysconf_priv.h
--- a/usr.sbin/sysconf/sysconf_priv.h
+++ b/usr.sbin/sysconf/sysconf_priv.h
@@ -37,7 +37,7 @@
#define __unused /* not all compilers support attributes */
#endif
-#define SYSCONF_VERSION "2.2.1 2026-10-17"
+#define SYSCONF_VERSION "2.3 2026-10-26"
/* getopt(3) optstring; shared by parse_options() and find_target() */
#define OPTSTRING "AacdDEeFf:hij:k:lLnNqR:svVx"
@@ -63,10 +63,27 @@
char *piece;
};
+#define UCL_INDEX_COUNT LONG_MIN /* `name.#' read: the element count */
+#define UCL_INDEX_APPEND (LONG_MIN + 1) /* `name.#=value': one past the end */
+
/* Requests to process (one per name argument) */
struct request {
const char *name; /* directive name */
const char *newvalue; /* value to write (NULL for reads) */
+ const char *rawvalue; /* ucl: value as typed, before
+ newvalue took the library's
+ rendering (NULL otherwise) */
+ const char *ucl_base; /* ucl: path of the array behind a
+ `name.i...' request (NULL if none) */
+ char *ucl_label; /* ucl: `name.i...' as typed, then as
+ resolved; shown on reads */
+ long *ucl_idx; /* ucl: the indices as typed, outermost
+ first; negative counts from the end,
+ the last may be UCL_INDEX_COUNT or
+ UCL_INDEX_APPEND */
+ long *ucl_res; /* ucl: the same, resolved against the
+ arrays (negative: out of range) */
+ size_t ucl_nidx;
enum bsdconf_op op; /* assignment operator (writes) */
char listop; /* `+' append / `-' strike words */
char *value; /* value read from file(s) */
@@ -106,7 +123,14 @@
extern size_t conf_scanidx;
extern size_t write_idx;
extern int defaults_idx;
+extern unsigned char *conf_ro;
+extern bool ucl_target;
+extern bool pkg_target;
+extern size_t pkg_conf_idx;
+extern size_t pkg_repos_idx;
extern enum bsdconf_format format;
+extern enum bsdconf_format ucl_format;
+extern enum bsdconf_format pkg_format;
/* Display / mode flags */
extern const char *pgm;
@@ -162,6 +186,8 @@
/* sysconf_scan.c */
char *make_apply(enum bsdconf_op _op, const char *_old,
const char *_piece);
+int scan_record(uint32_t _line, const char *_directive,
+ const char *_value);
int scan_pass(int _sandbox);
/* sysconf_edit_assign.c */
@@ -206,6 +232,28 @@
void usage(void);
void help(void);
+/* sysconf_ucl.c (ucl_object_t is only known with <ucl.h>) */
+struct ucl_object_s;
+int ucl_register(void);
+int ucl_scan_fd(int _fd);
+void ucl_canon_requests(void);
+void ucl_index_requests(void);
+bool ucl_list_merge(struct request *_req);
+char *ucl_render(const struct ucl_object_s *_obj);
+int ucl_do_writes(void);
+
+/* sysconf_ucl_text.c */
+char *ucl_text_edit(const char *_text, size_t _len,
+ const char *_name, const struct ucl_object_s *_val,
+ const char *_raw, const char *_style_src,
+ size_t _style_len, bool *_changed);
+char *ucl_text_ready(const char *_text, size_t _len,
+ size_t *_outlen);
+
+/* sysconf_pkg.c */
+int pkg_register(void);
+void pkg_prepare(void);
+
/* sysconf_poudriere.c */
int poudriere_longopt_p(const char *_arg);
int poudriere_dispatch(int _argc, char **_argv, int _tgt);
diff --git a/usr.sbin/sysconf/sysconf_resolve.c b/usr.sbin/sysconf/sysconf_resolve.c
--- a/usr.sbin/sysconf/sysconf_resolve.c
+++ b/usr.sbin/sysconf/sysconf_resolve.c
@@ -71,6 +71,9 @@
const char *env = NULL;
const struct bsdconf_format_def *def;
+ if (ucl_register() != 0)
+ err(EXIT_FAILURE, "ucl");
+
if (target == NULL) {
warnx("no target provided");
usage();
@@ -85,6 +88,9 @@
usage();
}
+ /* UCL files (ucl, and pkg derived from it) are libucl's to parse */
+ ucl_target = (format == ucl_format || format == pkg_format);
+
if (file != NULL) {
char path[PATH_MAX];
const char *prefix;
@@ -164,6 +170,9 @@
defaults_idx = 0;
write_idx++;
}
+
+ /* pkg(8) read-only base repos and the local override file */
+ pkg_prepare();
}
/*
diff --git a/usr.sbin/sysconf/sysconf_scan.c b/usr.sbin/sysconf/sysconf_scan.c
--- a/usr.sbin/sysconf/sysconf_scan.c
+++ b/usr.sbin/sysconf/sysconf_scan.c
@@ -13,6 +13,13 @@
#include "sysconf_priv.h"
+/*
+ * Set while a value arrives already unquoted (the ucl target hands over
+ * the library's rendering, not file text), so scan_cb() must not unquote
+ * it a second time.
+ */
+static bool scan_raw;
+
/*
* Apply a make(1)-style assignment operator to the running effective value
* of a directive. `+=' appends with a single space (as make(1) does);
@@ -72,7 +79,8 @@
if ((vcopy = strdup(value)) == NULL)
return (-1);
- bsdconf_unquote(vcopy);
+ if (!scan_raw)
+ bsdconf_unquote(vcopy);
for (n = 0; n < ndumps; n++) {
if (strcmp(dumps[n].name, directive) != 0)
@@ -154,15 +162,19 @@
if (strcmp(reqs[n].name, directive) != 0)
continue;
/*
- * The defaults file is read-only: it informs the effective
+ * Read-only files (the loader defaults, and pkg's base
+ * repository files under /etc/pkg) inform the effective
* value seen by reads, checks, and list edits, but can
* never satisfy a removal (nothing there may be removed).
*/
- if (reqs[n].remove && (int)conf_scanidx == defaults_idx)
+ if (reqs[n].remove &&
+ ((int)conf_scanidx == defaults_idx ||
+ (conf_ro != NULL && conf_ro[conf_scanidx])))
continue;
if ((copy = strdup(value)) == NULL)
return (-1);
- bsdconf_unquote(copy);
+ if (!scan_raw)
+ bsdconf_unquote(copy);
/*
* An identical make(1) `+=value' line already in the file
@@ -215,6 +227,23 @@
return (0);
}
+/*
+ * Record one already-unquoted `directive'/`value' pair for the file at
+ * conf_scanidx, exactly as scan_cb() would from file text. Used by the
+ * ucl target, whose values come from libucl rather than a bsdconf parse.
+ */
+int
+scan_record(uint32_t line, const char *directive, const char *value)
+{
+ int rv;
+
+ scan_raw = true;
+ rv = scan_cb(NULL, line, (char *)(uintptr_t)directive,
+ (char *)(uintptr_t)value);
+ scan_raw = false;
+ return (rv);
+}
+
/*
* Parse every existing backing file in deterministic order, feeding each
* statement through scan_cb(). With `sandbox' set (pure read operations),
@@ -232,6 +261,10 @@
if ((fds = calloc(nconf_files, sizeof(*fds))) == NULL)
err(EXIT_FAILURE, NULL);
+ /* Compare requested values in the library's own rendering */
+ if (ucl_target)
+ ucl_canon_requests();
+
for (n = 0; n < nconf_files; n++) {
fds[n] = open(conf_files[n], O_RDONLY);
if (fds[n] < 0 && errno != ENOENT)
@@ -262,11 +295,19 @@
if (fds[n] < 0)
continue;
conf_scanidx = n;
- if (bsdconf_fparse(NULL, fds[n], scan_cb, processing) != 0)
+ if (ucl_target) {
+ if (ucl_scan_fd(fds[n]) != 0)
+ err(EXIT_FAILURE, "%s", conf_files[n]);
+ } else if (bsdconf_fparse(NULL, fds[n], scan_cb,
+ processing) != 0)
err(EXIT_FAILURE, "%s", conf_files[n]);
close(fds[n]);
}
+ /* `name[i]' and `name[#]' resolve against the arrays just read */
+ if (ucl_target)
+ ucl_index_requests();
+
free(fds);
return (EXIT_SUCCESS);
}
diff --git a/usr.sbin/sysconf/sysconf_ucl.h b/usr.sbin/sysconf/sysconf_ucl.h
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl.h
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Shared by the ucl target's files (sysconf_ucl*.c). Not for the rest
+ * of sysconf. Include after sysconf_priv.h.
+ */
+
+#ifndef _SYSCONF_UCL_H_
+#define _SYSCONF_UCL_H_
+
+#include <stdbool.h>
+#include <stddef.h>
+
+#include <ucl.h>
+
+#define UCL_PARSE_FLAGS (UCL_PARSER_DISABLE_MACRO | UCL_PARSER_NO_FILEVARS)
+
+/* A flattened tree: dotted path -> rendering, last definition wins. */
+struct flat {
+ char **names;
+ char **values;
+ size_t n;
+};
+
+/* One statement located in the text */
+struct ustmt {
+ size_t start; /* first byte of the key */
+ size_t end; /* past the value and its terminator */
+ size_t val_start; /* value text (after the separator) */
+ size_t val_end; /* past the value text */
+ size_t line_start; /* start of the line holding the key */
+ size_t line_end; /* past the newline ending the statement */
+ size_t obj_open; /* position of `{' when is_obj */
+ char sep; /* ':' or '=' or 0 (bare `key value') */
+ char term; /* ',' or ';' or 0 */
+ bool alone; /* nothing else shares its lines */
+ bool is_obj; /* value is `{ ... }' */
+ bool is_arr; /* value is `[ ... ]' */
+ bool quoted; /* value is a quoted string */
+};
+
+/* One container (the file, or the inside of `{ ... }') */
+struct uobj {
+ size_t open; /* position of `{' (SIZE_MAX for the file) */
+ size_t close; /* position of `}' (or text length) */
+ size_t nstmts; /* statements directly inside */
+ struct ustmt first; /* first statement (when nstmts > 0) */
+ struct ustmt prev; /* statement before last (nstmts > 1) */
+ struct ustmt last; /* last statement (when nstmts > 0) */
+ struct ustmt scalar; /* first non-object statement */
+ bool has_scalar; /* `scalar' is valid */
+ bool blank_between; /* a blank line separates statements */
+};
+
+/* Style a new statement should take */
+struct ustyle {
+ char sep; /* ':' '=' or 0 */
+ char term; /* ',' ';' or 0 */
+ char obj_sep; /* separator used before `{' */
+ bool last_bare; /* members terminated except the last */
+ bool quote_keys; /* keys are written "quoted" */
+ char indent[64]; /* one indentation unit */
+};
+
+/* Growable output buffer */
+struct ubuf {
+ char *p;
+ size_t len;
+ size_t cap;
+};
+
+/* sysconf_ucl.c */
+int ucl_walk(const ucl_object_t *_obj, const char *_prefix,
+ int (*_cb)(const char *, const char *, ucl_type_t,
+ void *), void *_arg);
+ucl_object_t *ucl_value(const char *_text);
+
+/* sysconf_ucl_index.c */
+long ucl_resolve_index(long _idx, unsigned int _size);
+char *ucl_index_label(const struct request *_req, size_t _depth);
+void ucl_relabel(struct request *_req);
+void ucl_split_index(const char *_base);
+
+/* sysconf_ucl_list.c */
+ucl_object_t *ucl_list_apply(const char *_curtext, const char *_words,
+ int _op);
+
+/* sysconf_ucl_file.c */
+char *slurp(const char *_path, size_t *_lenp);
+char *slurp_fd(int _fd, const char *_path, size_t *_lenp);
+ucl_object_t *ucl_parse_text(const char *_path, const char *_text,
+ size_t _len, const char **_errp);
+void ucl_replace_file(const char *_path, const char *_text,
+ size_t _len);
+void flat_set(struct flat *_f, const char *_name,
+ const char *_value);
+void flat_remove(struct flat *_f, const char *_name);
+int flat_cb(const char *_name, const char *_value,
+ ucl_type_t _type, void *_arg);
+void flat_free(struct flat *_f);
+bool flat_equal(const struct flat *_a, const struct flat *_b);
+size_t ucl_write_target(const struct request *_req);
+ucl_object_t *ucl_reparse(ucl_object_t *_top, const char *_path,
+ const char *_text, size_t _len);
+
+/* sysconf_ucl_lex.c */
+size_t skip_ws(const char *_s, size_t _n, size_t _i, bool _newlines,
+ bool _seps);
+size_t skip_quoted(const char *_s, size_t _n, size_t _i);
+size_t skip_balanced(const char *_s, size_t _n, size_t _i);
+bool comment_at(const char *_s, size_t _n, size_t _i);
+bool key_char(char _c);
+size_t line_begin(const char *_s, size_t _i);
+bool blank_span(const char *_s, size_t _from, size_t _to);
+bool tail_span(const char *_s, size_t _n, size_t _from,
+ size_t _to);
+int parse_stmt(const char *_s, size_t _n, size_t _i,
+ struct ustmt *_st);
+int enum_object(const char *_s, size_t _n, size_t _i,
+ const char *_want, struct ustmt *_match, struct uobj *_obj);
+
+/* sysconf_ucl_style.c */
+void detect_indent(const char *_s, size_t _n, char *_out,
+ size_t _outsize);
+void detect_style(const char *_s, size_t _n, struct ustyle *_sty);
+bool key_bare_safe_len(const char *_k, size_t _n);
+char *quote_value(const char *_v);
+bool quoted_by_choice(const char *_oldtext, size_t _oldlen);
+char *render_value(const ucl_object_t *_val, const char *_raw,
+ const char *_oldtext, size_t _oldlen, bool _oldquoted);
+void ubuf_add(struct ubuf *_b, const char *_s, size_t _n);
+void ubuf_str(struct ubuf *_b, const char *_s);
+char *splice(const char *_s, size_t _n, size_t _at, size_t _from,
+ const char *_ins);
+char *splice_after(const char *_s, size_t _n, size_t _at, char _term,
+ const char *_ins);
+void emit_new(struct ubuf *_b, char **_comps, int _ncomps,
+ const char *_vtext, const struct ustyle *_sty,
+ const char *_prefix, size_t _prefix_len, int _depth,
+ bool _oneline);
+
+/* sysconf_ucl_array.c */
+char *array_edit(const char *_text, size_t _len, size_t _open,
+ size_t _anchor, const ucl_object_t *_want, char **_wantr,
+ size_t _nwant, const char *_indent, bool *_changed);
+
+#endif /* !_SYSCONF_UCL_H_ */
diff --git a/usr.sbin/sysconf/sysconf_ucl.c b/usr.sbin/sysconf/sysconf_ucl.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl.c
@@ -0,0 +1,266 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * The ucl target: UCL files read with libucl(3), the same private library
+ * pkg(8) links. libbsdconf supplies only the target bookkeeping (keyword,
+ * default paths, ordered sources); it never sees the file text. A bug in
+ * the parser is therefore the bug pkg already has, and reaching it from
+ * here is part of the point.
+ *
+ * Each backing file is parsed on its own, in sourcing order, and every
+ * scalar is reported under its dotted path (`FreeBSD-ports.enabled'). The
+ * last file to define a path is authoritative, as with every other target;
+ * the files are deliberately not merged by one parser, which would hide
+ * where a value came from and make -F a lie. Arrays are leaves and read
+ * back in compact JSON; nested objects are walked, not reported.
+ *
+ * A write changes only the bytes it must. The statement is located in
+ * the original text and its value replaced in the file's own style (see
+ * sysconf_ucl_text.c); nothing else moves. libucl then re-reads the
+ * result, and the write is refused unless the parsed tree differs from
+ * the original in exactly the requested key. Macros (`.include' and
+ * friends) are not expanded, so a parse never pulls in another file, and
+ * no variable handler is installed, so `${ABI}' stays literal.
+ *
+ * Values given on the command line are parsed as UCL so that `yes', `10',
+ * `[ a, b ]', and `{ k = v }' land with the types pkg would read; the
+ * file receives the text as typed (booleans in the spelling the file
+ * already uses). A value that does not parse cleanly to the very string
+ * typed is a string, quoted in the file, so a URL or anything the grammar
+ * finds special still round-trips. Reads report the library's rendering
+ * (`yes' comes back `true').
+ *
+ * A key that itself contains `.' cannot be addressed; the separator is the
+ * path. libucl keeps no line numbers, so trails show line 0.
+ */
+
+#include <ctype.h>
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+enum bsdconf_format ucl_format;
+bool ucl_target;
+
+/*
+ * Register the ucl target (and, derived from it, pkg) with libbsdconf so
+ * the usual target resolution applies. Idempotent.
+ */
+int
+ucl_register(void)
+{
+ static bool done;
+ struct bsdconf_format_def def;
+
+ if (done)
+ return (0);
+ if (bsdconf_format_derive(BSDCONF_FORMAT_GENERIC, &def) != 0)
+ return (-1);
+ def.keyword = "ucl";
+ def.path = NULL;
+ def.sources = NULL;
+ if (bsdconf_format_register(&def, &ucl_format) != 0)
+ return (-1);
+ if (pkg_register() != 0)
+ return (-1);
+ done = true;
+ return (0);
+}
+
+/*
+ * Render a leaf for sysconf's string world: strings as themselves,
+ * booleans and numbers as the library prints them, arrays as compact
+ * JSON. Returns newly allocated storage.
+ */
+char *
+ucl_render(const ucl_object_t *obj)
+{
+ char *out;
+ unsigned char *emitted;
+
+ switch (ucl_object_type(obj)) {
+ case UCL_ARRAY:
+ case UCL_OBJECT:
+ emitted = ucl_object_emit(obj, UCL_EMIT_JSON_COMPACT);
+ if (emitted == NULL)
+ err(EXIT_FAILURE, NULL);
+ return ((char *)emitted);
+ default:
+ if ((out = strdup(ucl_object_tostring_forced(obj))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ return (out);
+ }
+}
+
+/*
+ * Hand every scalar under `obj' to `cb' with its dotted path and type.
+ * A key repeated in one file is an implicit array to libucl, which
+ * presents its first element; that is the value reported (and the
+ * statement a write edits), as pkg(8) would see it.
+ */
+int
+ucl_walk(const ucl_object_t *obj, const char *prefix,
+ int (*cb)(const char *, const char *, ucl_type_t, void *), void *arg)
+{
+ const char *key;
+ const ucl_object_t *cur;
+ ucl_object_iter_t it;
+ char *value;
+ char qname[4096];
+ int rv = 0;
+
+ if ((it = ucl_object_iterate_new(obj)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ while (rv == 0 && (cur = ucl_object_iterate_safe(it, true)) != NULL) {
+ if ((key = ucl_object_key(cur)) == NULL)
+ continue;
+ if (prefix == NULL)
+ rv = snprintf(qname, sizeof(qname), "%s", key);
+ else
+ rv = snprintf(qname, sizeof(qname), "%s.%s", prefix,
+ key);
+ if (rv < 0 || rv >= (int)sizeof(qname)) {
+ errno = ENAMETOOLONG;
+ rv = -1;
+ break;
+ }
+ rv = 0;
+ if (ucl_object_type(cur) == UCL_OBJECT) {
+ rv = ucl_walk(cur, qname, cb, arg);
+ continue;
+ }
+ value = ucl_render(cur);
+ rv = cb(qname, value, ucl_object_type(cur), arg);
+ free(value);
+ }
+ ucl_object_iterate_free(it);
+ return (rv);
+}
+
+static int
+scan_record_cb(const char *name, const char *value, ucl_type_t type,
+ void *arg __unused)
+{
+
+ /* A path into an array is an index chain, not a key */
+ if (type == UCL_ARRAY)
+ ucl_split_index(name);
+ return (scan_record(0, name, value));
+}
+
+/*
+ * scan_pass() body for one backing file already open on `fd'. Returns
+ * zero, or -1 with errno set; a syntax error is fatal with the parser's
+ * message, as a bsdconf parse error would be.
+ */
+int
+ucl_scan_fd(int fd)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ const char *perr;
+ char *ready;
+ char *text;
+ size_t len;
+ size_t rlen;
+ int rv;
+
+ text = slurp_fd(fd, conf_files[conf_scanidx], &len);
+ ready = ucl_text_ready(text, len, &rlen);
+ free(text);
+ if ((parser = ucl_parser_new(UCL_PARSE_FLAGS)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ if (rlen > 0 && !ucl_parser_add_string(parser, ready, rlen)) {
+ perr = ucl_parser_get_error(parser);
+ errx(EXIT_FAILURE, "%s: %s", conf_files[conf_scanidx],
+ perr != NULL ? perr : "parse error");
+ }
+ free(ready);
+ if (rlen == 0 || (top = ucl_parser_get_object(parser)) == NULL) {
+ ucl_parser_free(parser);
+ return (0);
+ }
+ rv = ucl_walk(top, NULL, scan_record_cb, NULL);
+ ucl_object_unref(top);
+ ucl_parser_free(parser);
+ return (rv);
+}
+
+/*
+ * True when `s', outside its quoted strings, holds a comment, a newline,
+ * or a `.' where a statement could begin (the start, or after `{', `;'
+ * or `,'). The parse here runs with macros off, which skips a `.include'
+ * like a comment, but pkg(8) expands them; a value that could carry one
+ * must not be written as typed.
+ */
+static bool
+has_extra(const char *s)
+{
+ size_t i;
+ size_t n;
+ char prev = '{';
+
+ n = strlen(s);
+ for (i = 0; i < n; i++) {
+ if (s[i] == '"' || s[i] == '\'') {
+ i = skip_quoted(s, n, i) - 1;
+ prev = '"';
+ } else if (comment_at(s, n, i) || s[i] == '\n' ||
+ (s[i] == '.' && (prev == '{' || prev == ';' ||
+ prev == ',')))
+ return (true);
+ else if (s[i] != ' ' && s[i] != '\t' && s[i] != '\r')
+ prev = s[i];
+ }
+ return (false);
+}
+
+/*
+ * Parse `text' as a UCL value. A clean parse to one value whose string
+ * form is the text itself (or any non-string type with nothing extra;
+ * see has_extra()) is taken as typed; anything else (a `#' the grammar
+ * read as a comment, quotes it stripped, a second statement after a
+ * newline) is kept as a raw string so what was typed is what lands in
+ * the file, and nothing more than one value ever does.
+ */
+ucl_object_t *
+ucl_value(const char *text)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top = NULL;
+ ucl_object_t *val = NULL;
+ const ucl_object_t *v;
+ char *chunk;
+
+ if (asprintf(&chunk, "v = %s\n", text) < 0)
+ err(EXIT_FAILURE, NULL);
+ if ((parser = ucl_parser_new(UCL_PARSE_FLAGS)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ /* The grammar reads nothing as null; nothing typed is "" */
+ if (*text != '\0' &&
+ ucl_parser_add_string(parser, chunk, strlen(chunk)) &&
+ (top = ucl_parser_get_object(parser)) != NULL &&
+ (v = ucl_object_lookup(top, "v")) != NULL &&
+ top->len == 1 && v->next == NULL) {
+ if (ucl_object_type(v) != UCL_STRING ?
+ !has_extra(text) :
+ strcmp(ucl_object_tostring(v), text) == 0)
+ val = ucl_object_copy(v);
+ }
+ if (top != NULL)
+ ucl_object_unref(top);
+ ucl_parser_free(parser);
+ free(chunk);
+ if (val == NULL &&
+ (val = ucl_object_fromstring_common(text, 0, UCL_STRING_RAW)) ==
+ NULL)
+ err(EXIT_FAILURE, NULL);
+ return (val);
+}
+
diff --git a/usr.sbin/sysconf/sysconf_ucl_array.c b/usr.sbin/sysconf/sysconf_ucl_array.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl_array.c
@@ -0,0 +1,458 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Edits that add or strike elements of a UCL array, one element at a
+ * time, in the style of the elements around them.
+ */
+
+
+#include <ctype.h>
+#include <stdckdint.h>
+
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+/* One element located inside `[ ... ]' */
+struct uelem {
+ size_t start; /* first byte of the element */
+ size_t end; /* past the element and its terminator */
+ size_t val_end; /* past the element text */
+ size_t line_start; /* start of the line holding it */
+ size_t line_end; /* past the newline ending it */
+ char term; /* ',' or 0 */
+ bool alone; /* nothing else shares its lines */
+ bool quoted; /* a quoted string */
+ bool keep; /* survives the edit */
+};
+
+/*
+ * Enumerate the elements of the array whose `[' is at `open'. Returns
+ * the count (malloc'd into `*elemsp') and the position of `]' in
+ * `*closep', or -1 on a lexing failure.
+ */
+static int
+enum_array(const char *s, size_t n, size_t open, struct uelem **elemsp,
+ size_t *closep)
+{
+ struct uelem *elems = NULL;
+ struct uelem e;
+ size_t cap = 0;
+ size_t i;
+ size_t j;
+ size_t k;
+ int count = 0;
+
+ for (i = open + 1;;) {
+ j = skip_ws(s, n, i, true, true);
+ if (j >= n)
+ return (-1);
+ if (s[j] == ']') {
+ *closep = j;
+ break;
+ }
+ memset(&e, 0, sizeof(e));
+ e.start = j;
+ switch (s[j]) {
+ case '{':
+ case '[':
+ k = skip_balanced(s, n, j);
+ break;
+ case '"':
+ case '\'':
+ e.quoted = true;
+ k = skip_quoted(s, n, j);
+ break;
+ default:
+ k = j;
+ while (k < n && s[k] != '\n' && s[k] != ',' &&
+ s[k] != ']' && !comment_at(s, n, k))
+ k++;
+ while (k > j && (s[k - 1] == ' ' || s[k - 1] == '\t' ||
+ s[k - 1] == '\r'))
+ k--;
+ break;
+ }
+ if (k == j)
+ return (-1);
+ e.val_end = k;
+ k = skip_ws(s, n, k, false, false);
+ if (k < n && s[k] == ',') {
+ e.term = ',';
+ e.end = k + 1;
+ } else
+ e.end = e.val_end;
+ e.line_start = line_begin(s, e.start);
+ for (k = e.end; k < n && s[k] != '\n'; k++)
+ continue;
+ e.line_end = (k < n) ? k + 1 : k;
+ e.alone = blank_span(s, e.line_start, e.start) &&
+ tail_span(s, n, e.end, k);
+ if ((size_t)count == cap) {
+ cap = cap ? cap * 2 : 8;
+ if ((elems = reallocarray(elems, cap,
+ sizeof(*elems))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ }
+ elems[count++] = e;
+ i = e.end;
+ }
+ *elemsp = elems;
+ return (count);
+}
+
+/* What libucl makes of the element text `s[0,n)': NULL if nothing */
+static ucl_object_t *
+elem_object(const char *s, size_t n)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top = NULL;
+ ucl_object_t *val = NULL;
+ const ucl_object_t *v;
+ char *chunk;
+ size_t size;
+
+ if (ckd_add(&size, n, sizeof("v = \n")))
+ errx(EXIT_FAILURE, "%s", strerror(EOVERFLOW));
+ if ((chunk = malloc(size)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ memcpy(chunk, "v = ", 4);
+ memcpy(chunk + 4, s, n);
+ memcpy(chunk + 4 + n, "\n", 2);
+ if ((parser = ucl_parser_new(UCL_PARSE_FLAGS)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ if (ucl_parser_add_string(parser, chunk, strlen(chunk)) &&
+ (top = ucl_parser_get_object(parser)) != NULL &&
+ (v = ucl_object_lookup(top, "v")) != NULL)
+ val = ucl_object_copy(v);
+ if (top != NULL)
+ ucl_object_unref(top);
+ ucl_parser_free(parser);
+ free(chunk);
+ return (val);
+}
+
+/* Byte range to cut from the text */
+struct ucut {
+ size_t from;
+ size_t to;
+};
+
+static int
+ucut_cmp(const void *a, const void *b)
+{
+ const struct ucut *x = a;
+ const struct ucut *y = b;
+
+ return (x->from < y->from ? -1 : x->from > y->from);
+}
+
+/*
+ * Change the array statement `st' so it holds the elements of `want':
+ * elements no longer wanted lose their own lines (or their slot in a
+ * one-liner), new ones join after the last survivor in the style of the
+ * element before them. Only an edit that keeps the survivors in their
+ * old order and appends the rest is made this way; anything else, and
+ * any text this lexer cannot follow, returns NULL so the caller falls
+ * back to replacing the whole value. Returns the new text otherwise,
+ * with `*changed' telling whether it differs.
+ */
+char *
+array_edit(const char *text, size_t len, size_t open, size_t anchor,
+ const ucl_object_t *want, char **wantr, size_t nwant,
+ const char *indent, bool *changed)
+{
+ struct ucut *cuts = NULL;
+ struct uelem *elems = NULL;
+ struct uelem *after = NULL;
+ struct ubuf b = { NULL, 0, 0 };
+ const struct uelem *last;
+ ucl_object_t *obj;
+ const ucl_object_t *cur;
+ ucl_object_iter_t it;
+ char **oldr = NULL;
+ char *model = NULL;
+ char *out = NULL;
+ char *prefix = NULL;
+ char *tmp;
+ char *vtext;
+ char *er;
+ size_t close;
+ size_t ncuts = 0;
+ size_t nkept = 0;
+ size_t nnew;
+ size_t at;
+ size_t i;
+ size_t k;
+ size_t w;
+ int n;
+ int n2;
+ bool anyterm = false;
+ bool last_bare;
+ bool oneline;
+ bool quoted = false;
+ bool choice = false;
+ bool space_after = true;
+ char term;
+
+ *changed = false;
+ if ((n = enum_array(text, len, open, &elems, &close)) < 0)
+ return (NULL);
+ if (n > 0 && (oldr = calloc((size_t)n, sizeof(*oldr))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (i = 0; i < (size_t)n; i++) {
+ if ((obj = elem_object(text + elems[i].start,
+ elems[i].val_end - elems[i].start)) == NULL)
+ goto out;
+ oldr[i] = ucl_render(obj);
+ ucl_object_unref(obj);
+ if (elems[i].term != 0) {
+ anyterm = true;
+ if (elems[i].end < len && text[elems[i].end] != ' ')
+ space_after = false;
+ }
+ if (elems[i].quoted) {
+ quoted = true;
+ if (quoted_by_choice(text + elems[i].start,
+ elems[i].val_end - elems[i].start))
+ choice = true;
+ }
+ }
+ last_bare = (n > 1 && anyterm && elems[n - 1].term == 0);
+ oneline = (memchr(text + open, '\n',
+ close - open) == NULL);
+
+ /* Same count: the elements that differ change where they stand */
+ if ((size_t)n == nwant) {
+ if ((out = strdup(text)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (i = (size_t)n; i > 0; i--) {
+ if (strcmp(oldr[i - 1], wantr[i - 1]) == 0)
+ continue;
+ cur = ucl_array_find_index(want, (unsigned int)(i - 1));
+ if (text[elems[i - 1].start] == '[' &&
+ ucl_object_type(cur) == UCL_ARRAY) {
+ /* A nested array is edited in its own span */
+ const ucl_object_t *sub;
+ ucl_object_iter_t it2;
+ char **subr;
+ size_t nalloc;
+ size_t nsub = 0;
+ bool ch = false;
+
+ if (ckd_add(&nalloc, ucl_array_size(cur), 1))
+ errx(EXIT_FAILURE, "%s",
+ strerror(EOVERFLOW));
+ if ((subr = calloc(nalloc,
+ sizeof(*subr))) == NULL ||
+ (it2 = ucl_object_iterate_new(cur)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ while ((sub = ucl_object_iterate_safe(it2,
+ false)) != NULL)
+ subr[nsub++] = ucl_render(sub);
+ ucl_object_iterate_free(it2);
+ tmp = array_edit(out, strlen(out),
+ elems[i - 1].start, elems[i - 1].start,
+ cur, subr, nsub, indent, &ch);
+ for (w = 0; w < nsub; w++)
+ free(subr[w]);
+ free(subr);
+ if (tmp != NULL) {
+ free(out);
+ out = tmp;
+ if (ch)
+ *changed = true;
+ continue;
+ }
+ }
+ /*
+ * Quotes the old content forced do not bind the
+ * new value unless a neighbour quotes by choice.
+ */
+ er = ucl_render(cur);
+ vtext = render_value(cur, er, text + elems[i - 1].start,
+ elems[i - 1].val_end - elems[i - 1].start,
+ elems[i - 1].quoted && choice);
+ free(er);
+ tmp = splice(out, strlen(out), elems[i - 1].start,
+ elems[i - 1].val_end, vtext);
+ free(vtext);
+ free(out);
+ out = tmp;
+ *changed = true;
+ }
+ goto out;
+ }
+
+ /* Survivors in their old order, then the newcomers */
+ for (i = 0; i < (size_t)n; i++) {
+ for (w = 0; w < nwant; w++)
+ if (strcmp(oldr[i], wantr[w]) == 0)
+ break;
+ if (w == nwant)
+ continue;
+ if (nkept >= nwant || strcmp(oldr[i], wantr[nkept]) != 0)
+ goto out;
+ elems[i].keep = true;
+ nkept++;
+ }
+ for (w = nkept; w < nwant; w++)
+ for (i = 0; i < (size_t)n; i++)
+ if (strcmp(oldr[i], wantr[w]) == 0)
+ goto out;
+ nnew = nwant - nkept;
+ if (nnew == 0 && nkept == (size_t)n) {
+ if ((out = strdup(text)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ goto out;
+ }
+
+ /* The last element as it was models the newcomers' style */
+ if (n > 0) {
+ last = &elems[n - 1];
+ if ((model = strndup(text + last->start,
+ last->val_end - last->start)) == NULL ||
+ (prefix = strndup(text + last->line_start,
+ last->start - last->line_start)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ } else {
+ k = line_begin(text, anchor);
+ if (asprintf(&prefix, "%.*s%s", (int)(anchor - k),
+ text + k, indent) < 0)
+ err(EXIT_FAILURE, NULL);
+ }
+
+ /* Cut what is not kept, from the end so earlier offsets hold */
+ if ((cuts = calloc((size_t)n + 1, sizeof(*cuts))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ last = NULL;
+ for (i = 0; i < (size_t)n; i++) {
+ if (elems[i].keep) {
+ last = &elems[i];
+ continue;
+ }
+ if (elems[i].alone) {
+ cuts[ncuts].from = elems[i].line_start;
+ cuts[ncuts].to = elems[i].line_end;
+ } else {
+ /*
+ * Sharing a line: take the element, the blanks
+ * after it, and -- for a bare last element -- the
+ * comma that preceded it.
+ */
+ at = elems[i].start;
+ k = elems[i].end;
+ while (k < len && (text[k] == ' ' || text[k] == '\t'))
+ k++;
+ if (elems[i].term == 0) {
+ while (at > elems[i].line_start &&
+ (text[at - 1] == ' ' || text[at - 1] == '\t'))
+ at--;
+ if (at > elems[i].line_start &&
+ text[at - 1] == ',') {
+ at--;
+ k = elems[i].end;
+ } else
+ at = elems[i].start;
+ }
+ cuts[ncuts].from = at;
+ cuts[ncuts].to = k;
+ }
+ ncuts++;
+ }
+ /*
+ * In a style that terminates every element but the last, the new
+ * last survivor goes bare when nothing will follow it.
+ */
+ if (last_bare && nnew == 0 && last != NULL && last->term != 0) {
+ cuts[ncuts].from = last->end - 1;
+ cuts[ncuts].to = last->end;
+ ncuts++;
+ }
+ qsort(cuts, ncuts, sizeof(*cuts), ucut_cmp);
+ for (i = 1; i < ncuts; i++) {
+ /* Merge overlapping cuts (a shared comma, for instance) */
+ if (cuts[i].from <= cuts[i - 1].to) {
+ if (cuts[i].to > cuts[i - 1].to)
+ cuts[i - 1].to = cuts[i].to;
+ memmove(cuts + i, cuts + i + 1,
+ (ncuts - i - 1) * sizeof(*cuts));
+ ncuts--;
+ i--;
+ }
+ }
+ if ((out = strdup(text)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (i = ncuts; i > 0; i--) {
+ tmp = splice(out, strlen(out), cuts[i - 1].from,
+ cuts[i - 1].to, "");
+ free(out);
+ out = tmp;
+ }
+ if (nnew == 0) {
+ *changed = true;
+ goto out;
+ }
+
+ /* Newcomers after the last survivor, or after `[' */
+ if ((n2 = enum_array(out, strlen(out), open, &after,
+ &close)) < 0 || (size_t)n2 != nkept) {
+ free(out);
+ out = NULL;
+ goto out;
+ }
+ last = (n2 > 0) ? &after[n2 - 1] : NULL;
+ at = (last != NULL) ? last->end : open + 1;
+ term = (last != NULL && last->term == 0 && (anyterm || oneline)) ?
+ ',' : '\0';
+ if ((it = ucl_object_iterate_new(want)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ w = 0;
+ while ((cur = ucl_object_iterate_safe(it, false)) != NULL) {
+ if (w++ < nkept)
+ continue;
+ er = ucl_render(cur);
+ vtext = render_value(cur, er, model != NULL ? model : "",
+ model != NULL ? strlen(model) : 0, quoted);
+ free(er);
+ if (oneline) {
+ if (space_after || last == NULL)
+ ubuf_str(&b, " ");
+ ubuf_str(&b, vtext);
+ if (w < nwant)
+ ubuf_str(&b, ",");
+ } else {
+ ubuf_str(&b, "\n");
+ ubuf_str(&b, prefix);
+ ubuf_str(&b, vtext);
+ if (anyterm && (w < nwant || !last_bare))
+ ubuf_str(&b, ",");
+ }
+ free(vtext);
+ }
+ ucl_object_iterate_free(it);
+ if (oneline && last == NULL && at == close)
+ ubuf_str(&b, " ");
+ tmp = splice_after(out, strlen(out), at, term, b.p);
+ free(out);
+ out = tmp;
+ *changed = true;
+out:
+ for (i = 0; i < (size_t)n; i++)
+ free(oldr[i]);
+ free(oldr);
+ free(elems);
+ free(after);
+ free(cuts);
+ free(model);
+ free(prefix);
+ free(b.p);
+ return (out);
+}
+
diff --git a/usr.sbin/sysconf/sysconf_ucl_file.c b/usr.sbin/sysconf/sysconf_ucl_file.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl_file.c
@@ -0,0 +1,309 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * File text for the ucl target: read, parse, replace, and the flat
+ * tree a write is checked against.
+ */
+
+
+#include <sys/stat.h>
+
+#include <ctype.h>
+#include <stdckdint.h>
+
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+/*
+ * Read all of the file open on `fd' (named `path' in errors). Returns
+ * malloc'd text. A null character is fatal: UCL is text, and every span
+ * below is measured with strlen(3).
+ */
+char *
+slurp_fd(int fd, const char *path, size_t *lenp)
+{
+ struct stat sb;
+ char *text;
+ size_t got;
+ size_t size;
+ ssize_t r;
+
+ if (fstat(fd, &sb) != 0)
+ err(EXIT_FAILURE, "%s", path);
+ if (sb.st_size < 0 || ckd_add(&size, sb.st_size, 1))
+ errx(EXIT_FAILURE, "%s: %s", path, strerror(EFBIG));
+ if ((text = malloc(size)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (got = 0; got < size - 1; got += (size_t)r) {
+ r = read(fd, text + got, size - 1 - got);
+ if (r < 0)
+ err(EXIT_FAILURE, "%s", path);
+ if (r == 0)
+ break;
+ }
+ if (memchr(text, '\0', got) != NULL)
+ errx(EXIT_FAILURE, "%s: contains a null character", path);
+ text[got] = '\0';
+ *lenp = got;
+ return (text);
+}
+
+/* Read all of `path' (missing is empty). Returns malloc'd text. */
+char *
+slurp(const char *path, size_t *lenp)
+{
+ char *text;
+ int fd;
+
+ *lenp = 0;
+ if ((fd = open(path, O_RDONLY)) < 0) {
+ if (errno != ENOENT)
+ err(EXIT_FAILURE, "%s", path);
+ if ((text = strdup("")) == NULL)
+ err(EXIT_FAILURE, NULL);
+ return (text);
+ }
+ text = slurp_fd(fd, path, lenp);
+ close(fd);
+ return (text);
+}
+
+/* Parse `text'; an empty text is an empty object. Fatal on error. */
+ucl_object_t *
+ucl_parse_text(const char *path, const char *text, size_t len,
+ const char **errp)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top = NULL;
+ const char *perr;
+ char *ready;
+ size_t rlen;
+
+ if (errp != NULL)
+ *errp = NULL;
+ ready = ucl_text_ready(text, len, &rlen);
+ if ((parser = ucl_parser_new(UCL_PARSE_FLAGS)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ if (rlen > 0) {
+ if (!ucl_parser_add_string(parser, ready, rlen)) {
+ perr = ucl_parser_get_error(parser);
+ if (perr == NULL)
+ perr = "parse error";
+ if (errp == NULL)
+ errx(EXIT_FAILURE, "%s: %s", path, perr);
+ if ((*errp = strdup(perr)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ ucl_parser_free(parser);
+ free(ready);
+ return (NULL);
+ }
+ top = ucl_parser_get_object(parser);
+ }
+ free(ready);
+ ucl_parser_free(parser);
+ if (top == NULL && (top = ucl_object_typed_new(UCL_OBJECT)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ return (top);
+}
+
+/*
+ * Replace `path' atomically with `text', keeping the mode and (best
+ * effort) the ownership of an existing file. A symbolic link is
+ * followed: the file it names is replaced, and the link stays. Only a
+ * regular file can be replaced so, as bsdconf_put() does for the other
+ * targets, anything else is refused.
+ */
+void
+ucl_replace_file(const char *path, const char *text, size_t len)
+{
+ struct stat sb;
+ mode_t mode;
+ mode_t mask;
+ size_t off;
+ ssize_t w;
+ bool exists;
+ int fd;
+ char rpath[PATH_MAX];
+ char tmp[PATH_MAX];
+
+ if (realpath(path, rpath) != NULL)
+ exists = (stat(rpath, &sb) == 0);
+ else if (errno == ENOENT) {
+ if (strlcpy(rpath, path, sizeof(rpath)) >= sizeof(rpath))
+ errx(EXIT_FAILURE, "%s: %s", path,
+ strerror(ENAMETOOLONG));
+ exists = false;
+ } else
+ err(EXIT_FAILURE, "%s", path);
+ if (exists && !S_ISREG(sb.st_mode))
+ errx(EXIT_FAILURE, "%s: %s", path, strerror(EINVAL));
+ if (snprintf(tmp, sizeof(tmp), "%s.XXXXXX", rpath) >=
+ (int)sizeof(tmp))
+ errx(EXIT_FAILURE, "%s: %s", rpath, strerror(ENAMETOOLONG));
+ if ((fd = mkstemp(tmp)) < 0)
+ err(EXIT_FAILURE, "%s", tmp);
+ if (exists)
+ mode = sb.st_mode & 07777;
+ else {
+ mask = umask(0);
+ umask(mask);
+ mode = 0666 & ~mask;
+ }
+ if (fchmod(fd, mode) != 0) {
+ unlink(tmp);
+ err(EXIT_FAILURE, "%s", tmp);
+ }
+ if (exists)
+ (void)fchown(fd, sb.st_uid, sb.st_gid); /* best effort */
+ for (off = 0; off < len; off += (size_t)w) {
+ if ((w = write(fd, text + off, len - off)) < 0) {
+ unlink(tmp);
+ err(EXIT_FAILURE, "%s", tmp);
+ }
+ }
+ if (fsync(fd) != 0 || close(fd) != 0 || rename(tmp, rpath) != 0) {
+ unlink(tmp);
+ err(EXIT_FAILURE, "%s", rpath);
+ }
+}
+
+/*
+ * A flattened tree: dotted path -> rendering, last definition wins.
+ * Used to prove an edit did exactly what was asked.
+ */
+void
+flat_set(struct flat *f, const char *name, const char *value)
+{
+ char **tmp;
+ size_t i;
+
+ for (i = 0; i < f->n; i++) {
+ if (strcmp(f->names[i], name) == 0) {
+ free(f->values[i]);
+ if ((f->values[i] = strdup(value)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ return;
+ }
+ }
+ if ((tmp = reallocarray(f->names, f->n + 1, sizeof(*tmp))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ f->names = tmp;
+ if ((tmp = reallocarray(f->values, f->n + 1, sizeof(*tmp))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ f->values = tmp;
+ if ((f->names[f->n] = strdup(name)) == NULL ||
+ (f->values[f->n] = strdup(value)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ f->n++;
+}
+
+/* Drop `name' and everything beneath `name.' */
+void
+flat_remove(struct flat *f, const char *name)
+{
+ size_t i;
+ size_t j;
+ size_t len = strlen(name);
+
+ for (i = j = 0; i < f->n; i++) {
+ if (strcmp(f->names[i], name) == 0 ||
+ (strncmp(f->names[i], name, len) == 0 &&
+ f->names[i][len] == '.')) {
+ free(f->names[i]);
+ free(f->values[i]);
+ continue;
+ }
+ f->names[j] = f->names[i];
+ f->values[j] = f->values[i];
+ j++;
+ }
+ f->n = j;
+}
+
+int
+flat_cb(const char *name, const char *value, ucl_type_t type __unused,
+ void *arg)
+{
+
+ flat_set(arg, name, value);
+ return (0);
+}
+
+void
+flat_free(struct flat *f)
+{
+ size_t i;
+
+ for (i = 0; i < f->n; i++) {
+ free(f->names[i]);
+ free(f->values[i]);
+ }
+ free(f->names);
+ free(f->values);
+}
+
+/* True when the two flattenings agree exactly */
+bool
+flat_equal(const struct flat *a, const struct flat *b)
+{
+ size_t i;
+ size_t j;
+
+ if (a->n != b->n)
+ return (false);
+ for (i = 0; i < a->n; i++) {
+ for (j = 0; j < b->n; j++)
+ if (strcmp(a->names[i], b->names[j]) == 0)
+ break;
+ if (j == b->n || strcmp(a->values[i], b->values[j]) != 0)
+ return (false);
+ }
+ return (true);
+}
+
+/*
+ * The file that receives a write request: the authoritative file when it
+ * is writable, otherwise the target's default (pkg sends a repository key
+ * to the local repos file and a global key to pkg.conf).
+ */
+size_t
+ucl_write_target(const struct request *req)
+{
+
+ if (req->srcidx < 0 || req->srcidx == defaults_idx ||
+ (conf_ro != NULL && conf_ro[req->srcidx])) {
+ if (pkg_target && strchr(req->ucl_base != NULL ?
+ req->ucl_base : req->name, '.') != NULL)
+ return (pkg_repos_idx);
+ return (write_idx);
+ }
+ return ((size_t)req->srcidx);
+}
+
+/*
+ * Replace the parse `top' of a file being edited with that of its
+ * current text, so later requests in the same run see earlier edits.
+ * Text libucl rejects is fatal here, before anything is written.
+ */
+ucl_object_t *
+ucl_reparse(ucl_object_t *top, const char *path, const char *text,
+ size_t len)
+{
+ ucl_object_t *fresh;
+ const char *perr = NULL;
+
+ ucl_object_unref(top);
+ if ((fresh = ucl_parse_text(path, text, len, &perr)) == NULL)
+ errx(EXIT_FAILURE,
+ "%s: edit would not parse (%s); file left unchanged",
+ path, perr != NULL ? perr : "unknown error");
+ return (fresh);
+}
+
diff --git a/usr.sbin/sysconf/sysconf_ucl_index.c b/usr.sbin/sysconf/sysconf_ucl_index.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl_index.c
@@ -0,0 +1,271 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Index chains for the ucl target. Under an array a dotted component
+ * is an index (name.i, name.i.j, name.-1); name.# reads the count and
+ * name.#=value appends. Under an object the same component is a key.
+ */
+
+
+#include <ctype.h>
+
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+/*
+ * The index `idx' against an array of `size' elements: a negative
+ * index counts from the end, UCL_INDEX_APPEND is one past the end.
+ * Returns -1 when the index falls before the first element.
+ */
+long
+ucl_resolve_index(long idx, unsigned int size)
+{
+
+ if (idx == UCL_INDEX_APPEND)
+ return ((long)size);
+ if (idx >= 0)
+ return (idx);
+ if (idx == UCL_INDEX_COUNT || (long)size + idx < 0)
+ return (-1);
+ return ((long)size + idx);
+}
+
+/*
+ * The resolved form of an indexed request cut after `depth' indices
+ * (`BAR.0' for depth 1); an index not yet resolved shows as typed.
+ * Returns newly allocated storage.
+ */
+char *
+ucl_index_label(const struct request *req, size_t depth)
+{
+ char *label;
+ char *tmp;
+ size_t i;
+ int rv;
+
+ if ((label = strdup(req->ucl_base)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (i = 0; i < depth; i++) {
+ if (req->ucl_idx[i] == UCL_INDEX_COUNT)
+ break;
+ if (req->ucl_res[i] >= 0)
+ rv = asprintf(&tmp, "%s.%ld", label, req->ucl_res[i]);
+ else if (req->ucl_idx[i] == UCL_INDEX_APPEND)
+ rv = asprintf(&tmp, "%s.#", label);
+ else
+ rv = asprintf(&tmp, "%s.%ld", label, req->ucl_idx[i]);
+ if (rv < 0)
+ err(EXIT_FAILURE, NULL);
+ free(label);
+ label = tmp;
+ }
+ return (label);
+}
+
+/* Show the request under its resolved indices from here on */
+void
+ucl_relabel(struct request *req)
+{
+ char *label;
+
+ label = ucl_index_label(req, req->ucl_nidx);
+ if (req->name == req->ucl_label)
+ req->name = label;
+ free(req->ucl_label);
+ req->ucl_label = label;
+}
+
+/*
+ * The scan found an array at `base'. A request whose path runs on past
+ * it, `base.i', `base.i.j', or `base.i.#', is an index chain; split it
+ * once into the array's path (which the scan then matches) and its
+ * indices. ucl_index_requests() puts the typed form back for the
+ * report. A component that is not a number or `#' is a key, and the
+ * request is left to read as unknown. A negative index counts from
+ * the end; `#' is the count on a read and one past the end on a set.
+ */
+void
+ucl_split_index(const char *base)
+{
+ struct request *req;
+ const char *p;
+ char *end;
+ long *idx;
+ size_t blen;
+ size_t d;
+ size_t n;
+ unsigned int r;
+
+ blen = strlen(base);
+ for (r = 0; r < nreqs; r++) {
+ req = &reqs[r];
+ if (req->ucl_base != NULL ||
+ strncmp(req->name, base, blen) != 0 ||
+ req->name[blen] != '.')
+ continue;
+ if ((idx = calloc(strlen(req->name + blen) / 2 + 1,
+ sizeof(*idx))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ n = 0;
+ p = req->name + blen;
+ while (*p == '.') {
+ if (p[1] == '#' && p[2] == '\0') {
+ idx[n++] = UCL_INDEX_COUNT;
+ p += 2;
+ break;
+ }
+ if (!isdigit((unsigned char)p[1]) &&
+ !(p[1] == '-' && isdigit((unsigned char)p[2])))
+ break;
+ /*
+ * libucl counts elements in unsigned int; past
+ * that it is no index (and LONG_MIN and its
+ * successor are the count and append markers).
+ */
+ errno = 0;
+ idx[n] = strtol(p + 1, &end, 10);
+ if (errno == ERANGE || idx[n] <= UCL_INDEX_APPEND ||
+ (idx[n] < 0 ? -(unsigned long)idx[n] :
+ (unsigned long)idx[n]) > UINT_MAX)
+ break;
+ n++;
+ p = end;
+ }
+ if (*p != '\0') {
+ free(idx);
+ continue;
+ }
+ if (idx[n - 1] == UCL_INDEX_COUNT) {
+ if (req->remove)
+ errx(EXIT_FAILURE,
+ "%s: the count cannot be removed",
+ req->name);
+ if (req->listop != '\0')
+ errx(EXIT_FAILURE,
+ "%s: the count cannot be edited",
+ req->name);
+ if (req->newvalue != NULL)
+ idx[n - 1] = UCL_INDEX_APPEND;
+ }
+ if ((req->ucl_base = strdup(base)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ req->ucl_idx = idx;
+ req->ucl_nidx = n;
+ if ((req->ucl_res = calloc(n, sizeof(*idx))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (d = 0; d < n; d++)
+ req->ucl_res[d] = -1;
+ req->ucl_label = ucl_index_label(req, n);
+ req->name = req->ucl_base;
+ }
+}
+
+/*
+ * Keep the value as typed (for the file) and rewrite the request's
+ * value in the library's rendering so `-c' and the write echo compare
+ * like with like (`enabled=yes' against a file that reads back `true').
+ * Runs at the start of every scan; indexed requests go back to matching
+ * on the array's path.
+ */
+void
+ucl_canon_requests(void)
+{
+ ucl_object_t *val;
+ unsigned int n;
+
+ for (n = 0; n < nreqs; n++) {
+ if (reqs[n].ucl_base != NULL)
+ reqs[n].name = reqs[n].ucl_base;
+ if (reqs[n].newvalue == NULL || reqs[n].remove ||
+ reqs[n].listop != '\0' || reqs[n].rawvalue != NULL)
+ continue;
+ reqs[n].rawvalue = reqs[n].newvalue;
+ val = ucl_value(reqs[n].newvalue);
+ reqs[n].newvalue = ucl_render(val);
+ ucl_object_unref(val);
+ }
+}
+
+/*
+ * After a scan, turn the array an indexed request matched into the one
+ * element (or the count) asked for, and show the request as typed. An
+ * index past the end is an unknown directive; a value that is not an
+ * array cannot be indexed.
+ */
+void
+ucl_index_requests(void)
+{
+ struct request *req;
+ ucl_object_t *top;
+ const ucl_object_t *arr;
+ const ucl_object_t *elem;
+ char *label;
+ char *v;
+ size_t d;
+ long i;
+ unsigned int n;
+
+ for (n = 0; n < nreqs; n++) {
+ req = &reqs[n];
+ if (req->ucl_base == NULL)
+ continue;
+ /* -V shows the files' assignments, which are of the array */
+ if (!verbose_trail)
+ req->name = req->ucl_label;
+ if (!req->found)
+ continue;
+ top = ucl_value(req->value);
+ arr = top;
+ v = NULL;
+ for (d = 0; d < req->ucl_nidx; d++) {
+ if (ucl_object_type(arr) != UCL_ARRAY) {
+ label = ucl_index_label(req, d);
+ errx(EXIT_FAILURE, "%s: not an array", label);
+ }
+ if (req->ucl_idx[d] == UCL_INDEX_COUNT) {
+ if (asprintf(&v, "%u", ucl_array_size(arr)) < 0)
+ err(EXIT_FAILURE, NULL);
+ break;
+ }
+ i = ucl_resolve_index(req->ucl_idx[d],
+ ucl_array_size(arr));
+ if (i < 0)
+ break;
+ req->ucl_res[d] = i;
+ elem = ucl_array_find_index(arr, (unsigned int)i);
+ if (elem == NULL) {
+ if (d + 1 == req->ucl_nidx &&
+ req->newvalue != NULL && !req->remove &&
+ (unsigned int)i == ucl_array_size(arr)) {
+ /* One past the end: the set appends */
+ if ((v = strdup("")) == NULL)
+ err(EXIT_FAILURE, NULL);
+ }
+ break;
+ }
+ if (d + 1 == req->ucl_nidx)
+ v = ucl_render(elem);
+ arr = elem;
+ }
+ if (v != NULL)
+ ucl_relabel(req);
+ if (v == NULL) {
+ /* Out of range: nothing to read, set, or remove */
+ req->name = req->ucl_label;
+ req->found = 0;
+ req->srcidx = -1;
+ memset(req->infile, 0, nconf_files);
+ }
+ ucl_object_unref(top);
+ free(req->value);
+ req->value = v;
+ }
+}
+
diff --git a/usr.sbin/sysconf/sysconf_ucl_lex.c b/usr.sbin/sysconf/sysconf_ucl_lex.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl_lex.c
@@ -0,0 +1,338 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Lexer for UCL text. It finds spans; it does not decide what a file
+ * means. libucl does that, after the edit.
+ */
+
+
+#include <ctype.h>
+#include <stdckdint.h>
+
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+/* Block comments nest, as libucl reads them */
+static size_t
+skip_comment(const char *s, size_t n, size_t i)
+{
+ int depth;
+
+ if (s[i] == '#') {
+ while (i < n && s[i] != '\n')
+ i++;
+ return (i);
+ }
+ if (s[i] == '/' && i + 1 < n && s[i + 1] == '*') {
+ depth = 1;
+ i += 2;
+ while (i + 1 < n) {
+ if (s[i] == '*' && s[i + 1] == '/') {
+ i += 2;
+ if (--depth == 0)
+ return (i);
+ } else if (s[i] == '/' && s[i + 1] == '*') {
+ depth++;
+ i += 2;
+ } else
+ i++;
+ }
+ return (n);
+ }
+ return (i);
+}
+
+bool
+comment_at(const char *s, size_t n, size_t i)
+{
+
+ return (s[i] == '#' ||
+ (s[i] == '/' && i + 1 < n && s[i + 1] == '*'));
+}
+
+/*
+ * Skip blanks, optionally newlines and statement separators, and
+ * comments.
+ */
+size_t
+skip_ws(const char *s, size_t n, size_t i, bool newlines, bool seps)
+{
+ size_t j;
+
+ for (;;) {
+ while (i < n && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r' ||
+ (newlines && s[i] == '\n') ||
+ (seps && (s[i] == ',' || s[i] == ';'))))
+ i++;
+ if (i >= n || !comment_at(s, n, i))
+ return (i);
+ j = skip_comment(s, n, i);
+ if (j == i)
+ return (i);
+ i = j;
+ }
+}
+
+size_t
+skip_quoted(const char *s, size_t n, size_t i)
+{
+ char q = s[i++];
+
+ while (i < n && s[i] != q) {
+ if (s[i] == '\\' && i + 1 < n)
+ i++;
+ i++;
+ }
+ return (i < n ? i + 1 : n);
+}
+
+/* Skip a balanced `{ ... }' or `[ ... ]', strings and comments aware */
+size_t
+skip_balanced(const char *s, size_t n, size_t i)
+{
+ int depth = 0;
+
+ for (; i < n; i++) {
+ if (s[i] == '"' || s[i] == '\'') {
+ i = skip_quoted(s, n, i) - 1;
+ continue;
+ }
+ if (comment_at(s, n, i)) {
+ i = skip_comment(s, n, i) - 1;
+ continue;
+ }
+ if (s[i] == '{' || s[i] == '[')
+ depth++;
+ else if (s[i] == '}' || s[i] == ']') {
+ if (--depth == 0)
+ return (i + 1);
+ }
+ }
+ return (n);
+}
+
+bool
+key_char(char c)
+{
+
+ return (!(c == ' ' || c == '\t' || c == '\r' || c == '\n' ||
+ c == ':' || c == '=' || c == '{' || c == ';' ||
+ c == ',' || c == '}' || c == '#' || c == '"'));
+}
+
+size_t
+line_begin(const char *s, size_t i)
+{
+
+ while (i > 0 && s[i - 1] != '\n')
+ i--;
+ return (i);
+}
+
+bool
+blank_span(const char *s, size_t from, size_t to)
+{
+
+ for (; from < to; from++)
+ if (s[from] != ' ' && s[from] != '\t' && s[from] != '\r')
+ return (false);
+ return (true);
+}
+
+/*
+ * True when `s[from,to)' is blank or a comment that rides out the line:
+ * a statement or element followed only by that owns its line, and a
+ * removal takes the comment with it, as sysrc(8) does.
+ */
+bool
+tail_span(const char *s, size_t n, size_t from, size_t to)
+{
+
+ while (from < to && (s[from] == ' ' || s[from] == '\t' ||
+ s[from] == '\r'))
+ from++;
+ if (from >= to)
+ return (true);
+ if (!comment_at(s, n, from))
+ return (false);
+ /* A block comment running past the line is not ours to take */
+ from = skip_comment(s, n, from);
+ return (from <= to && blank_span(s, from, to));
+}
+
+/*
+ * Parse the statement starting at `i' (its key). Returns zero and
+ * fills `st', or -1 when the text there is not a statement.
+ */
+int
+parse_stmt(const char *s, size_t n, size_t i, struct ustmt *st)
+{
+ size_t j;
+ size_t k;
+
+ memset(st, 0, sizeof(*st));
+ st->start = i;
+ if (s[i] == '"')
+ j = skip_quoted(s, n, i);
+ else {
+ j = i;
+ while (j < n && key_char(s[j]))
+ j++;
+ }
+ if (j == i)
+ return (-1);
+ j = skip_ws(s, n, j, false, false);
+ if (j < n && (s[j] == ':' || s[j] == '=')) {
+ st->sep = s[j];
+ j = skip_ws(s, n, j + 1, true, false);
+ }
+ st->val_start = j;
+ if (j >= n) {
+ st->val_end = j;
+ st->end = j;
+ goto lines;
+ }
+ switch (s[j]) {
+ case '{':
+ st->is_obj = true;
+ st->obj_open = j;
+ k = skip_balanced(s, n, j);
+ break;
+ case '[':
+ st->is_arr = true;
+ k = skip_balanced(s, n, j);
+ break;
+ case '"':
+ case '\'':
+ st->quoted = true;
+ k = skip_quoted(s, n, j);
+ break;
+ case '<':
+ if (j + 2 < n && s[j + 1] == '<' && isupper((unsigned char)
+ s[j + 2])) {
+ size_t w = j + 2;
+ size_t wl;
+ const char *nl;
+
+ while (w < n && isupper((unsigned char)s[w]))
+ w++;
+ wl = w - (j + 2);
+ k = n;
+ for (nl = s + w; (nl = memchr(nl, '\n',
+ (size_t)(s + n - nl))) != NULL; nl++) {
+ if ((size_t)(s + n - (nl + 1)) >= wl &&
+ memcmp(nl + 1, s + j + 2, wl) == 0 &&
+ (nl + 1 + wl == s + n ||
+ nl[1 + wl] == '\n' || nl[1 + wl] == '\r')) {
+ k = (size_t)(nl + 1 + wl - s);
+ break;
+ }
+ }
+ break;
+ }
+ /* FALLTHROUGH */
+ default:
+ k = j;
+ while (k < n && s[k] != '\n' && s[k] != ';' && s[k] != ',' &&
+ s[k] != '}' && !comment_at(s, n, k))
+ k++;
+ while (k > j && (s[k - 1] == ' ' || s[k - 1] == '\t' ||
+ s[k - 1] == '\r'))
+ k--;
+ break;
+ }
+ st->val_end = k;
+ k = skip_ws(s, n, k, false, false);
+ if (k < n && (s[k] == ',' || s[k] == ';')) {
+ st->term = s[k];
+ st->end = k + 1;
+ } else
+ st->end = st->val_end;
+lines:
+ st->line_start = line_begin(s, st->start);
+ for (k = st->end; k < n && s[k] != '\n'; k++)
+ continue;
+ st->line_end = (k < n) ? k + 1 : k;
+ st->alone = blank_span(s, st->line_start, st->start) &&
+ tail_span(s, n, st->end, (k < n) ? k : n);
+ return (0);
+}
+
+/*
+ * Enumerate the statements of the container whose interior starts at
+ * `i', recording its shape in `obj' and the first statement whose key is
+ * `want' in `match'. Returns 1 when `want' was found, 0 if not, -1 on a
+ * lexing failure.
+ */
+int
+enum_object(const char *s, size_t n, size_t i, const char *want,
+ struct ustmt *match, struct uobj *obj)
+{
+ struct ustmt st;
+ size_t j;
+ size_t klen;
+ size_t kstart;
+ int found = 0;
+ int nl;
+
+ obj->nstmts = 0;
+ obj->has_scalar = false;
+ obj->blank_between = false;
+ for (;;) {
+ j = skip_ws(s, n, i, true, true);
+ if (j >= n || s[j] == '}') {
+ obj->close = j;
+ return (found);
+ }
+ if (parse_stmt(s, n, j, &st) != 0)
+ return (-1);
+ if (obj->nstmts == 0)
+ obj->first = st;
+ else if (obj->nstmts == 1) {
+ nl = 0;
+ for (i = obj->first.end; i < st.start; i++)
+ if (s[i] == '\n')
+ nl++;
+ obj->blank_between = (nl >= 2);
+ }
+ if (obj->nstmts > 0)
+ obj->prev = obj->last;
+ obj->last = st;
+ obj->nstmts++;
+ /* `key [' and `key {' are idioms; scalars set the style */
+ if (!st.is_obj && !st.is_arr && !obj->has_scalar) {
+ obj->scalar = st;
+ obj->has_scalar = true;
+ }
+ if (s[st.start] == '"') {
+ kstart = st.start + 1;
+ /* A lone `"' at the end has no closing quote */
+ if (ckd_sub(&klen, skip_quoted(s, n, st.start) -
+ st.start, 2))
+ return (-1);
+ } else {
+ kstart = st.start;
+ klen = 0;
+ while (kstart + klen < n && key_char(s[kstart + klen]))
+ klen++;
+ }
+ /*
+ * A key repeated in one file is an implicit array to
+ * libucl, which presents its first element; edit that one.
+ */
+ if (!found && strlen(want) == klen &&
+ strncmp(s + kstart, want, klen) == 0) {
+ *match = st;
+ found = 1;
+ }
+ i = st.end;
+ }
+}
+
diff --git a/usr.sbin/sysconf/sysconf_ucl_list.c b/usr.sbin/sysconf/sysconf_ucl_list.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl_list.c
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * += and -= for the ucl target: elements on an array, words on a string.
+ */
+
+
+#include <ctype.h>
+
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+/* True when some element of `arr' renders as `want' */
+static bool
+ucl_array_has(const ucl_object_t *arr, const char *want)
+{
+ const ucl_object_t *cur;
+ ucl_object_iter_t it;
+ char *r;
+ bool found = false;
+
+ if ((it = ucl_object_iterate_new(arr)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ while (!found && (cur = ucl_object_iterate_safe(it, false)) != NULL) {
+ r = ucl_render(cur);
+ found = (strcmp(r, want) == 0);
+ free(r);
+ }
+ ucl_object_iterate_free(it);
+ return (found);
+}
+
+/*
+ * sysrc(8)-style `name+=word ...' and `name-=word ...' applied to the
+ * value rendered in `curtext' (NULL when unset). An array takes the
+ * words as elements, appended when absent or struck when present,
+ * with the survivors in their old order; anything else takes them as
+ * words of a string. Returns the new value.
+ */
+ucl_object_t *
+ucl_list_apply(const char *curtext, const char *words, int op)
+{
+ ucl_object_t *cur = NULL;
+ ucl_object_t *elem;
+ ucl_object_t *out;
+ const ucl_object_t *old;
+ ucl_object_iter_t it;
+ const char *token;
+ char *merged;
+ char *r;
+ char *word;
+ size_t len;
+
+ if (curtext != NULL)
+ cur = ucl_value(curtext);
+ if (cur == NULL || ucl_object_type(cur) != UCL_ARRAY) {
+ if (cur != NULL)
+ ucl_object_unref(cur);
+ merged = list_merge(curtext, words, op);
+ out = ucl_value(merged);
+ free(merged);
+ return (out);
+ }
+ if ((out = ucl_object_typed_new(UCL_ARRAY)) == NULL)
+ err(EXIT_FAILURE, NULL);
+
+ /* Copy the current elements, striking matches under `-' */
+ if ((it = ucl_object_iterate_new(cur)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ while ((old = ucl_object_iterate_safe(it, false)) != NULL) {
+ if (op == '-') {
+ r = ucl_render(old);
+ token = words;
+ while (*token != '\0') {
+ token += strspn(token, " \t");
+ if ((len = strcspn(token, " \t")) == 0)
+ break;
+ if (strlen(r) == len &&
+ strncmp(r, token, len) == 0)
+ break;
+ token += len;
+ }
+ free(r);
+ if (*token != '\0')
+ continue;
+ }
+ ucl_array_append(out, ucl_object_copy(old));
+ }
+ ucl_object_iterate_free(it);
+
+ /* Append the words not already present under `+' */
+ if (op == '+') {
+ token = words;
+ while (*token != '\0') {
+ token += strspn(token, " \t");
+ if ((len = strcspn(token, " \t")) == 0)
+ break;
+ if ((word = strndup(token, len)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ elem = ucl_value(word);
+ r = ucl_render(elem);
+ if (ucl_array_has(out, r))
+ ucl_object_unref(elem);
+ else
+ ucl_array_append(out, elem);
+ free(r);
+ free(word);
+ token += len;
+ }
+ }
+ ucl_object_unref(cur);
+ return (out);
+}
+
+/*
+ * merge_list_requests() for the ucl target: keep the words in
+ * `rawvalue' (the write applies them again to whatever the file holds
+ * by then, so requests on one array compose) and put the merged value,
+ * as the scan rendered it, in `newvalue' for -c and the echo. A value
+ * that is not an array is left to the word merge, words kept likewise.
+ * Returns true when the request was taken.
+ */
+bool
+ucl_list_merge(struct request *req)
+{
+ ucl_object_t *cur;
+ ucl_object_t *out;
+
+ req->rawvalue = req->newvalue;
+ if (req->value == NULL)
+ return (false);
+ cur = ucl_value(req->value);
+ if (ucl_object_type(cur) != UCL_ARRAY) {
+ ucl_object_unref(cur);
+ return (false);
+ }
+ ucl_object_unref(cur);
+ out = ucl_list_apply(req->value, req->rawvalue, req->listop);
+ req->newvalue = ucl_render(out);
+ ucl_object_unref(out);
+ return (true);
+}
+
diff --git a/usr.sbin/sysconf/sysconf_ucl_style.c b/usr.sbin/sysconf/sysconf_ucl_style.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl_style.c
@@ -0,0 +1,414 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * How a new UCL statement is spelled: the file's separator, terminator,
+ * quoting, boolean family, and indentation.
+ */
+
+
+#include <ctype.h>
+#include <stdckdint.h>
+
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+/* Detect the indentation unit used in `s' (first indented line) */
+void
+detect_indent(const char *s, size_t n, char *out, size_t outsize)
+{
+ size_t i;
+ size_t j;
+
+ for (i = 0; i < n; i = j + 1) {
+ for (j = i; j < n && s[j] != '\n'; j++)
+ continue;
+ if (j > i && (s[i] == ' ' || s[i] == '\t') &&
+ !blank_span(s, i, j)) {
+ size_t k = i;
+
+ while (k < j && (s[k] == ' ' || s[k] == '\t'))
+ k++;
+ if (k - i < outsize) {
+ memcpy(out, s + i, k - i);
+ out[k - i] = '\0';
+ return;
+ }
+ }
+ if (j >= n)
+ break;
+ }
+ strlcpy(out, " ", outsize);
+}
+
+/*
+ * Derive the style for new statements from `s' (the file being edited,
+ * or a style source when that file is empty): the separator and
+ * terminator of the first scalar statement, the separator before `{'
+ * of the first object statement, and the indentation unit.
+ */
+void
+detect_style(const char *s, size_t n, struct ustyle *sty)
+{
+ struct ustmt m;
+ struct ustmt st;
+ struct uobj in;
+ size_t i;
+ size_t j;
+ bool have_scalar = false;
+ bool have_obj = false;
+
+ sty->sep = ':';
+ sty->term = 0;
+ sty->obj_sep = ':';
+ sty->last_bare = false;
+ sty->quote_keys = false;
+ detect_indent(s, n, sty->indent, sizeof(sty->indent));
+ i = skip_ws(s, n, 0, true, false);
+ if (i < n && s[i] == '{')
+ i++; /* JSON-style outer object */
+ while (!(have_scalar && have_obj)) {
+ j = skip_ws(s, n, i, true, true);
+ if (j >= n || s[j] == '}' || parse_stmt(s, n, j, &st) != 0 ||
+ st.end <= j)
+ break;
+ if (!have_scalar && !have_obj)
+ sty->quote_keys = (s[st.start] == '"');
+ if (st.is_obj) {
+ if (!have_obj) {
+ sty->obj_sep = st.sep;
+ if (st.term != 0)
+ sty->term = st.term;
+ have_obj = true;
+ }
+ /*
+ * Scalars inside the first object set the style,
+ * including whether the last member goes bare
+ * (`a: 1,\n b: 2\n}').
+ */
+ if (!have_scalar &&
+ enum_object(s, n, st.obj_open + 1, "", &m,
+ &in) >= 0 && in.has_scalar) {
+ sty->sep = in.scalar.sep;
+ if (in.scalar.term != 0 || sty->term == 0)
+ sty->term = in.scalar.term;
+ sty->last_bare = (in.nstmts > 1 &&
+ in.scalar.term != 0 && in.last.term == 0);
+ have_scalar = true;
+ }
+ } else if (!have_scalar && !st.is_arr) {
+ sty->sep = st.sep;
+ if (st.term != 0 || sty->term == 0)
+ sty->term = st.term;
+ have_scalar = true;
+ }
+ i = st.end;
+ }
+}
+
+/* True when `v' can stand bare as a string value */
+static bool
+bare_safe(const char *v)
+{
+ const char *p;
+
+ if (*v == '\0')
+ return (false);
+ for (p = v; *p != '\0'; p++) {
+ if (!(isalnum((unsigned char)*p) || *p == '_' || *p == '-' ||
+ *p == '.' || *p == '+' || *p == '/' || *p == '@' ||
+ *p == '%' || *p == ':'))
+ return (false);
+ }
+ /* Would parse as something other than a string */
+ if (strcasecmp(v, "yes") == 0 || strcasecmp(v, "no") == 0 ||
+ strcasecmp(v, "true") == 0 || strcasecmp(v, "false") == 0 ||
+ strcasecmp(v, "on") == 0 || strcasecmp(v, "off") == 0 ||
+ strcasecmp(v, "null") == 0 || isdigit((unsigned char)*v))
+ return (false);
+ return (true);
+}
+
+/*
+ * True when `k' can stand bare as a libucl key: it must begin with a
+ * letter, a digit, `/' or `_', and continue with those or `-' or `.'.
+ * `*', `**', `[1m' and the like must be quoted or the library refuses
+ * the file; uclcmd(1) may leave `*' bare, which we quote for a parse
+ * and write quoted ourselves.
+ */
+static bool
+key_bare_safe(const char *k)
+{
+ const unsigned char *p;
+
+ if (*k == '\0')
+ return (false);
+ p = (const unsigned char *)k;
+ if (!(isalnum(*p) || *p == '/' || *p == '_' || *p >= 0x80))
+ return (false);
+ for (p++; *p != '\0'; p++) {
+ if (!(isalnum(*p) || *p == '_' || *p == '-' || *p == '.' ||
+ *p == '/' || *p >= 0x80))
+ return (false);
+ }
+ return (true);
+}
+
+bool
+key_bare_safe_len(const char *k, size_t n)
+{
+ char *tmp;
+ bool r;
+
+ if ((tmp = strndup(k, n)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ r = key_bare_safe(tmp);
+ free(tmp);
+ return (r);
+}
+
+char *
+quote_value(const char *v)
+{
+ char *out;
+ char *o;
+ const char *p;
+ size_t size;
+
+ if (ckd_mul(&size, strlen(v), 2) || ckd_add(&size, size, 3))
+ errx(EXIT_FAILURE, "%s", strerror(EOVERFLOW));
+ if ((out = malloc(size)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ o = out;
+ *o++ = '"';
+ for (p = v; *p != '\0'; p++) {
+ switch (*p) {
+ case '"':
+ case '\\':
+ *o++ = '\\';
+ *o++ = *p;
+ break;
+ case '\n':
+ *o++ = '\\';
+ *o++ = 'n';
+ break;
+ case '\t':
+ *o++ = '\\';
+ *o++ = 't';
+ break;
+ default:
+ *o++ = *p;
+ }
+ }
+ *o++ = '"';
+ *o = '\0';
+ return (out);
+}
+
+/*
+ * True when the old value, "quoted" in the text, could have stood bare:
+ * its author chose the quotes, and a new value keeps them. Quotes that
+ * only the old content called for (a space, say) do not bind the new.
+ */
+bool
+quoted_by_choice(const char *oldtext, size_t oldlen)
+{
+ char *inner;
+ bool r;
+
+ if (oldlen < 2 || oldtext[0] != '"')
+ return (true);
+ if ((inner = strndup(oldtext + 1, oldlen - 2)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ r = bare_safe(inner);
+ free(inner);
+ return (r);
+}
+
+/*
+ * The text to write for `val' (parsed from `raw', the value as typed):
+ * strings bare when safe and not previously quoted, otherwise quoted;
+ * booleans in the spelling family the old text used (`yes'/`no',
+ * `on'/`off', `true'/`false'); everything else as typed.
+ */
+char *
+render_value(const ucl_object_t *val, const char *raw, const char *oldtext,
+ size_t oldlen, bool oldquoted)
+{
+ const char *sv;
+ char *out;
+ bool b;
+
+ switch (ucl_object_type(val)) {
+ case UCL_STRING:
+ sv = ucl_object_tostring(val);
+ if (!oldquoted && bare_safe(sv))
+ out = strdup(sv);
+ else
+ out = quote_value(sv);
+ break;
+ case UCL_BOOLEAN:
+ b = ucl_object_toboolean(val);
+ if (oldlen == 3 && strncasecmp(oldtext, "yes", 3) == 0)
+ out = strdup(b ? "yes" : "no");
+ else if (oldlen == 2 && strncasecmp(oldtext, "no", 2) == 0)
+ out = strdup(b ? "yes" : "no");
+ else if (oldlen == 2 && strncasecmp(oldtext, "on", 2) == 0)
+ out = strdup(b ? "on" : "off");
+ else if (oldlen == 3 && strncasecmp(oldtext, "off", 3) == 0)
+ out = strdup(b ? "on" : "off");
+ else if (oldlen == 0)
+ out = strdup(raw);
+ else
+ out = strdup(b ? "true" : "false");
+ break;
+ default:
+ out = strdup(raw);
+ break;
+ }
+ if (out == NULL)
+ err(EXIT_FAILURE, NULL);
+ return (out);
+}
+
+void
+ubuf_add(struct ubuf *b, const char *s, size_t n)
+{
+ char *tmp;
+ size_t need;
+
+ if (ckd_add(&need, b->len, n) || ckd_add(&need, need, 1))
+ errx(EXIT_FAILURE, "%s", strerror(EOVERFLOW));
+ if (need > b->cap) {
+ if (ckd_mul(&b->cap, need, 2))
+ b->cap = need;
+ if ((tmp = realloc(b->p, b->cap)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ b->p = tmp;
+ }
+ memcpy(b->p + b->len, s, n);
+ b->len += n;
+ b->p[b->len] = '\0';
+}
+
+void
+ubuf_str(struct ubuf *b, const char *s)
+{
+
+ ubuf_add(b, s, strlen(s));
+}
+
+/* Splice: text[0,at) + ins + text[from,n) */
+char *
+splice(const char *s, size_t n, size_t at, size_t from, const char *ins)
+{
+ struct ubuf b = { NULL, 0, 0 };
+
+ /* A span the lexer got wrong must not read past the text */
+ if (at > from || from > n)
+ errx(EXIT_FAILURE, "splice [%zu,%zu) outside %zu bytes", at,
+ from, n);
+ ubuf_add(&b, s, at);
+ ubuf_str(&b, ins);
+ ubuf_add(&b, s + from, n - from);
+ return (b.p);
+}
+
+/*
+ * Insert `term' (when nonzero) right after the statement or element
+ * ending at `at', and `ins' after it; when only a comment follows on
+ * that line, `ins' goes at the end of the line instead so the comment
+ * stays with what it annotates.
+ */
+char *
+splice_after(const char *s, size_t n, size_t at, char term, const char *ins)
+{
+ struct ubuf b = { NULL, 0, 0 };
+ char *out;
+ char *tmp;
+ size_t nl;
+
+ for (nl = at; nl < n && s[nl] != '\n'; nl++)
+ continue;
+ if (blank_span(s, at, nl) || !tail_span(s, n, at, nl)) {
+ if (term != 0)
+ ubuf_add(&b, &term, 1);
+ ubuf_str(&b, ins);
+ out = splice(s, n, at, at, b.p);
+ free(b.p);
+ return (out);
+ }
+ out = splice(s, n, nl, nl, ins);
+ if (term != 0) {
+ ubuf_add(&b, &term, 1);
+ tmp = splice(out, strlen(out), at, at, b.p);
+ free(out);
+ free(b.p);
+ out = tmp;
+ }
+ return (out);
+}
+
+/* Separator text for a scalar statement in style `sep' */
+static const char *
+sep_text(char sep)
+{
+
+ return (sep == ':' ? ": " : sep == '=' ? " = " : " ");
+}
+
+/*
+ * Build `key<sep>value<term>' with nested objects for the remaining
+ * path components. Each line starts with `prefix' (the siblings'
+ * indentation) plus `depth' indentation units; with `oneline', the
+ * statement is laid out on a single line instead.
+ */
+void
+emit_new(struct ubuf *b, char **comps, int ncomps, const char *vtext,
+ const struct ustyle *sty, const char *prefix, size_t prefix_len,
+ int depth, bool oneline)
+{
+ char *q;
+ int d;
+
+ if (!oneline) {
+ ubuf_add(b, prefix, prefix_len);
+ for (d = 0; d < depth; d++)
+ ubuf_str(b, sty->indent);
+ }
+ if (sty->quote_keys || !key_bare_safe(comps[0])) {
+ q = quote_value(comps[0]);
+ ubuf_str(b, q);
+ free(q);
+ } else
+ ubuf_str(b, comps[0]);
+ if (ncomps == 1) {
+ ubuf_str(b, sep_text(sty->sep));
+ ubuf_str(b, vtext);
+ /* The leaf is the last member of whatever holds it */
+ if (sty->term != 0 && !(sty->last_bare && depth > 0))
+ ubuf_add(b, &sty->term, 1);
+ return;
+ }
+ ubuf_str(b, sty->obj_sep == ':' ? ": {" :
+ sty->obj_sep == '=' ? " = {" : " {");
+ ubuf_str(b, oneline ? " " : "\n");
+ emit_new(b, comps + 1, ncomps - 1, vtext, sty, prefix, prefix_len,
+ depth + 1, oneline);
+ if (oneline)
+ ubuf_str(b, " }");
+ else {
+ ubuf_str(b, "\n");
+ ubuf_add(b, prefix, prefix_len);
+ for (d = 0; d < depth; d++)
+ ubuf_str(b, sty->indent);
+ ubuf_str(b, "}");
+ }
+}
+
diff --git a/usr.sbin/sysconf/sysconf_ucl_text.c b/usr.sbin/sysconf/sysconf_ucl_text.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl_text.c
@@ -0,0 +1,447 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Surgical edits to UCL text for the ucl target.
+ *
+ * libucl(3) reads the file and decides what it means; it does not keep
+ * where in the text a value came from, and its emitter lays a file out
+ * its own way. Rewriting a whole file through the emitter would change
+ * every line a user did not ask about, so a write instead locates the
+ * one statement in the original bytes and changes only that span: the
+ * separator, the terminator, the quoting, the indentation, and even the
+ * spelling of a boolean are taken from the file, never from here. A
+ * missing key is added beside its siblings in their style; a missing
+ * object is appended in the style of the objects already there (or, for
+ * a new file, of a style source such as the file being overridden). A
+ * removal takes the statement's own lines and, when that empties the
+ * enclosing object, the object too.
+ *
+ * The lexer here only finds spans. It is deliberately not a parser of
+ * record: after every edit the caller re-reads the result with libucl
+ * and refuses the write unless the parsed tree differs from the
+ * original in exactly the requested key (see ucl_do_writes() in
+ * sysconf_ucl_write.c). Any construct this lexer misjudges therefore fails
+ * loudly and leaves the file alone rather than being silently mangled.
+ */
+
+#include <ctype.h>
+#include <stdckdint.h>
+
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+/*
+ * Apply one edit to `text': set dotted `name' to `val' (as typed in
+ * `raw'), or remove it when `val' is NULL. `style_src' supplies the
+ * style for a file with nothing to mimic (NULL for none). Returns the
+ * new text (caller frees); `*changed' tells whether anything differs.
+ * A path through a scalar is fatal; a key not found for removal returns
+ * the text unchanged.
+ */
+char *
+ucl_text_edit(const char *text, size_t len, const char *name,
+ const ucl_object_t *val, const char *raw, const char *style_src,
+ size_t style_len, bool *changed)
+{
+ struct ustyle sty;
+ struct uobj objs[32];
+ struct ustmt stmts[32];
+ struct ustmt match;
+ struct uobj obj;
+ struct ubuf b;
+ char *comps[32];
+ char *path;
+ char *out;
+ char *vtext;
+ char *p;
+ size_t at;
+ size_t interior;
+ size_t k;
+ int depth;
+ int found;
+ int level;
+ int ncomps = 0;
+ bool oneline;
+
+ *changed = false;
+ if ((path = strdup(name)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (p = path; ncomps < 32 && (comps[ncomps] = strsep(&p, ".")) !=
+ NULL; ncomps++)
+ continue;
+ if (ncomps == 0 || p != NULL)
+ errx(EXIT_FAILURE, "%s: path too deep", name);
+
+ /* Top level: the file, or one JSON-style outer object */
+ interior = 0;
+ objs[0].open = SIZE_MAX;
+ k = skip_ws(text, len, 0, true, false);
+ if (k < len && text[k] == '{') {
+ objs[0].open = k;
+ interior = k + 1;
+ }
+
+ /* Walk down the path, recording each container and statement */
+ for (level = 0; level < ncomps; level++) {
+ found = enum_object(text, len, interior, comps[level], &match,
+ &obj);
+ if (found < 0)
+ errx(EXIT_FAILURE, "%s: cannot locate `%s' in text",
+ name, comps[level]);
+ obj.open = objs[level].open;
+ objs[level] = obj;
+ if (!found)
+ break;
+ stmts[level] = match;
+ if (level + 1 == ncomps)
+ break;
+ if (!match.is_obj)
+ errx(EXIT_FAILURE, "%s: `%s' is not an object", name,
+ comps[level]);
+ objs[level + 1].open = match.obj_open;
+ interior = match.obj_open + 1;
+ }
+ /* `level' is the deepest container entered; `found' tells leaf */
+
+ if (val == NULL) {
+ struct ustmt victim;
+ size_t prev_term;
+
+ if (!found) {
+ free(path);
+ return (strdup(text));
+ }
+ /* Remove the statement, and any object it alone filled */
+ victim = stmts[level];
+ for (depth = level; depth > 0; depth--) {
+ if (objs[depth].nstmts != 1)
+ break;
+ victim = stmts[depth - 1];
+ }
+ /*
+ * In a style that terminates every member but the last
+ * (`a: 1,\n b: 2\n}'), removing the bare last member
+ * must also take the terminator its predecessor now
+ * carries in vain.
+ */
+ obj = objs[depth];
+ if (obj.nstmts > 1 && victim.term == 0 &&
+ victim.start == obj.last.start && obj.prev.term != 0 &&
+ obj.prev.end > 0 && obj.prev.end <= victim.start)
+ prev_term = obj.prev.end - 1;
+ else
+ prev_term = SIZE_MAX;
+ if (victim.alone) {
+ out = splice(text, len, victim.line_start,
+ victim.line_end, "");
+ if (prev_term != SIZE_MAX) {
+ char *out2;
+
+ out2 = splice(out, strlen(out), prev_term,
+ prev_term + 1, "");
+ free(out);
+ out = out2;
+ }
+ } else {
+ /*
+ * Sharing a line: take the statement, the blanks
+ * after it, and -- for a bare last member -- the
+ * separator that preceded it (`a: 1, b: 2' minus b
+ * is `a: 1').
+ */
+ at = victim.start;
+ k = victim.end;
+ while (k < len && (text[k] == ' ' || text[k] == '\t'))
+ k++;
+ if (prev_term != SIZE_MAX)
+ at = prev_term;
+ else if (victim.term == 0) {
+ while (at > victim.line_start &&
+ (text[at - 1] == ' ' || text[at - 1] == '\t'))
+ at--;
+ if (!(at > victim.line_start &&
+ (text[at - 1] == ',' || text[at - 1] == ';')))
+ at = victim.start;
+ else
+ at--;
+ }
+ /* Took the separator before it: keep what follows */
+ if (at < victim.start)
+ k = victim.end;
+ out = splice(text, len, at, k, "");
+ }
+ *changed = true;
+ free(path);
+ return (out);
+ }
+
+ if (found && match.is_arr && ucl_object_type(val) == UCL_ARRAY) {
+ /* Element by element when the array only grew or shrank */
+ const ucl_object_t *cur;
+ ucl_object_iter_t it;
+ char **wantr;
+ size_t nalloc;
+ size_t nwant = 0;
+ size_t w;
+
+ if (ckd_add(&nalloc, ucl_array_size(val), 1))
+ errx(EXIT_FAILURE, "%s", strerror(EOVERFLOW));
+ if ((wantr = calloc(nalloc, sizeof(*wantr))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ if ((it = ucl_object_iterate_new(val)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ while ((cur = ucl_object_iterate_safe(it, false)) != NULL)
+ wantr[nwant++] = ucl_render(cur);
+ ucl_object_iterate_free(it);
+ detect_indent(text, len, sty.indent, sizeof(sty.indent));
+ out = array_edit(text, len, match.val_start, match.start,
+ val, wantr, nwant, sty.indent, changed);
+ for (w = 0; w < nwant; w++)
+ free(wantr[w]);
+ free(wantr);
+ if (out != NULL) {
+ free(path);
+ return (out);
+ }
+ *changed = false;
+ }
+
+ if (found) {
+ /* Replace the value text in place */
+ vtext = render_value(val, raw, text + match.val_start,
+ match.val_end - match.val_start, match.quoted);
+ if (match.val_end - match.val_start == strlen(vtext) &&
+ strncmp(text + match.val_start, vtext,
+ strlen(vtext)) == 0) {
+ free(vtext);
+ free(path);
+ return (strdup(text));
+ }
+ out = splice(text, len, match.val_start, match.val_end, vtext);
+ free(vtext);
+ free(path);
+ *changed = true;
+ return (out);
+ }
+
+ /*
+ * Insert into container `level' the components from `level' on.
+ * The file's own style wins; one with nothing to say about
+ * terminators yet (new, or only single-member objects so far)
+ * borrows from the style source so that an override file keeps
+ * looking like the file it overrides.
+ */
+ if (len > 0 && !blank_span(text, 0, len))
+ detect_style(text, len, &sty);
+ else
+ detect_style("", 0, &sty);
+ if (sty.term == 0 && style_src != NULL) {
+ struct ustyle src;
+
+ detect_style(style_src, style_len, &src);
+ if (len == 0 || blank_span(text, 0, len))
+ sty = src;
+ else if (src.term != 0) {
+ sty.term = src.term;
+ sty.last_bare = src.last_bare;
+ }
+ }
+ obj = objs[level];
+ if (obj.has_scalar) {
+ /* Mimic the scalar siblings */
+ sty.sep = obj.scalar.sep;
+ if (obj.scalar.term != 0 ||
+ obj.scalar.start != obj.last.start)
+ sty.term = obj.scalar.term;
+ }
+ if (obj.nstmts > 0) {
+ sty.quote_keys = (text[obj.first.start] == '"');
+ if (obj.first.is_obj) {
+ sty.obj_sep = obj.first.sep;
+ if (!obj.has_scalar && obj.first.term != 0)
+ sty.term = obj.first.term;
+ }
+ }
+ /* A file that quotes its keys (JSON) gets quoted strings too */
+ vtext = render_value(val, raw, "", 0, sty.quote_keys);
+ memset(&b, 0, sizeof(b));
+ if (obj.open != SIZE_MAX) {
+ const char *prefix;
+ size_t prefix_len;
+ char sep1 = '\0';
+ struct ustmt last;
+
+ /* Inside `{ ... }': after the last member, or after `{' */
+ oneline = (memchr(text + obj.open, '\n',
+ obj.close - obj.open) == NULL);
+ if (obj.nstmts == 0) {
+ /*
+ * An empty object: members indent one unit past
+ * the line that opens it (the file's outer `{}'
+ * has no statement opening it).
+ */
+ at = obj.open + 1;
+ if (level > 0) {
+ prefix = text + stmts[level - 1].line_start;
+ prefix_len = stmts[level - 1].start -
+ stmts[level - 1].line_start;
+ } else {
+ prefix = "";
+ prefix_len = 0;
+ }
+ if (oneline) {
+ ubuf_str(&b, " ");
+ emit_new(&b, comps + level, ncomps - level,
+ vtext, &sty, "", 0, 0, true);
+ if (blank_span(text, at, obj.close) &&
+ obj.close == at)
+ ubuf_str(&b, " ");
+ } else {
+ ubuf_str(&b, "\n");
+ emit_new(&b, comps + level, ncomps - level,
+ vtext, &sty, prefix, prefix_len, 1, false);
+ }
+ } else {
+ /*
+ * After the last member. A style that terminates
+ * every member but the last (`a: 1,\n b: 2\n}')
+ * gets the terminator added to the old last member
+ * and the new one left bare; a one-liner needs a
+ * comma between members regardless.
+ */
+ last = obj.last;
+ at = last.end;
+ if (last.term == 0) {
+ if (sty.term != 0 || oneline)
+ sep1 = (sty.term != 0) ? sty.term : ',';
+ sty.term = 0;
+ } else
+ sty.term = last.term;
+ sty.last_bare = false; /* decided above */
+ prefix = text + last.line_start;
+ prefix_len = last.start - last.line_start;
+ if (oneline) {
+ ubuf_str(&b, " ");
+ emit_new(&b, comps + level, ncomps - level,
+ vtext, &sty, "", 0, 0, true);
+ } else {
+ ubuf_str(&b, "\n");
+ emit_new(&b, comps + level, ncomps - level,
+ vtext, &sty, prefix, prefix_len, 0, false);
+ }
+ }
+ out = splice_after(text, len, at, sep1, b.p);
+ } else {
+ /* Append at the end of the file */
+ at = len;
+ if (len > 0 && text[len - 1] != '\n')
+ ubuf_str(&b, "\n");
+ if (obj.nstmts > 0 && obj.blank_between &&
+ (obj.last.is_obj || ncomps - level > 1))
+ ubuf_str(&b, "\n");
+ emit_new(&b, comps + level, ncomps - level, vtext, &sty, "",
+ 0, 0, false);
+ ubuf_str(&b, "\n");
+ out = splice(text, len, at, at, b.p);
+ }
+ free(b.p);
+ free(vtext);
+ free(path);
+ *changed = true;
+ return (out);
+}
+
+/*
+ * Record every bare key under the container at `i' that libucl would
+ * refuse (`*', `**', `[1m', ...), then the keys of nested objects.
+ */
+static void
+collect_unsafe_keys(const char *s, size_t n, size_t i, size_t **at,
+ size_t **kl, size_t *nk)
+{
+ struct ustmt st;
+ size_t j;
+ size_t klen;
+ size_t *tmp;
+
+ for (;;) {
+ j = skip_ws(s, n, i, true, true);
+ if (j >= n || s[j] == '}')
+ return;
+ if (parse_stmt(s, n, j, &st) != 0)
+ return;
+ if (s[st.start] != '"') {
+ klen = 0;
+ while (st.start + klen < n &&
+ key_char(s[st.start + klen]))
+ klen++;
+ if (klen > 0 &&
+ !key_bare_safe_len(s + st.start, klen)) {
+ if ((tmp = reallocarray(*at, *nk + 1,
+ sizeof(**at))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ *at = tmp;
+ if ((tmp = reallocarray(*kl, *nk + 1,
+ sizeof(**kl))) == NULL)
+ err(EXIT_FAILURE, NULL);
+ *kl = tmp;
+ (*at)[*nk] = st.start;
+ (*kl)[*nk] = klen;
+ (*nk)++;
+ }
+ }
+ if (st.is_obj)
+ collect_unsafe_keys(s, n, st.obj_open + 1, at, kl, nk);
+ i = st.end;
+ }
+}
+
+/*
+ * A copy of `text' whose keys libucl will accept: a bare `*' (as
+ * uclcmd(1) may emit) and any other key that cannot stand bare is
+ * quoted. The file on disk is not changed; the caller parses the
+ * result and frees it.
+ */
+char *
+ucl_text_ready(const char *text, size_t len, size_t *outlen)
+{
+ size_t *at = NULL;
+ size_t *kl = NULL;
+ size_t nk = 0;
+ size_t i;
+ size_t k;
+ char *out;
+ char *q;
+ char *tmp;
+ char *key;
+
+ k = skip_ws(text, len, 0, true, false);
+ if (k < len && text[k] == '{')
+ collect_unsafe_keys(text, len, k + 1, &at, &kl, &nk);
+ else
+ collect_unsafe_keys(text, len, 0, &at, &kl, &nk);
+ if ((out = strndup(text, len)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ for (i = nk; i > 0; i--) {
+ if ((key = strndup(out + at[i - 1], kl[i - 1])) == NULL)
+ err(EXIT_FAILURE, NULL);
+ q = quote_value(key);
+ free(key);
+ tmp = splice(out, strlen(out), at[i - 1],
+ at[i - 1] + kl[i - 1], q);
+ free(q);
+ free(out);
+ out = tmp;
+ }
+ free(at);
+ free(kl);
+ *outlen = strlen(out);
+ return (out);
+}
diff --git a/usr.sbin/sysconf/sysconf_ucl_write.c b/usr.sbin/sysconf/sysconf_ucl_write.c
new file mode 100644
--- /dev/null
+++ b/usr.sbin/sysconf/sysconf_ucl_write.c
@@ -0,0 +1,442 @@
+/*
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ * Copyright (c) 2021-2026 Faraz Vahedi <kfv@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Writes for the ucl target. One file at a time: splice each request
+ * into the text, then let libucl confirm the result.
+ */
+
+
+#include <ctype.h>
+
+#include <ucl.h>
+
+#include "sysconf_priv.h"
+#include "sysconf_ucl.h"
+
+/*
+ * The element `idx[0..nidx)' names under `arr', or NULL if none;
+ * `res' receives the indices as resolved.
+ */
+static const ucl_object_t *
+ucl_array_walk(const ucl_object_t *arr, const long *idx, long *res,
+ size_t nidx)
+{
+ size_t d;
+
+ for (d = 0; d < nidx && arr != NULL; d++) {
+ if (ucl_object_type(arr) != UCL_ARRAY)
+ return (NULL);
+ res[d] = ucl_resolve_index(idx[d], ucl_array_size(arr));
+ if (res[d] < 0)
+ return (NULL);
+ arr = ucl_array_find_index(arr, (unsigned int)res[d]);
+ }
+ return (arr);
+}
+
+/*
+ * The array `arr' as the indices `idx[0..nidx)' leave it: without the
+ * element they name for a removal (`newel' NULL), or with that element
+ * set to `newel' (one past the end appends). `res' receives the
+ * indices as resolved; `*oldelp' the rendering of the element replaced
+ * or removed (NULL when there was none); `*parentp' the parent array,
+ * as it was, of the deepest step reached. An index that leads through
+ * something other than an array is fatal, named by `label'.
+ */
+static ucl_object_t *
+ucl_array_set(const ucl_object_t *arr, const long *idx, long *res,
+ size_t nidx, const ucl_object_t *newel, const char *label,
+ char **oldelp, const ucl_object_t **parentp)
+{
+ ucl_object_t *out;
+ ucl_object_t *sub;
+ const ucl_object_t *old;
+ ucl_object_iter_t it;
+ long i = 0;
+
+ if (ucl_object_type(arr) != UCL_ARRAY)
+ errx(EXIT_FAILURE, "%s: not an array", label);
+ *parentp = arr;
+ res[0] = ucl_resolve_index(idx[0], ucl_array_size(arr));
+ if ((out = ucl_object_typed_new(UCL_ARRAY)) == NULL ||
+ (it = ucl_object_iterate_new(arr)) == NULL)
+ err(EXIT_FAILURE, NULL);
+ while ((old = ucl_object_iterate_safe(it, false)) != NULL) {
+ if (i++ != res[0]) {
+ ucl_array_append(out, ucl_object_copy(old));
+ continue;
+ }
+ if (nidx > 1) {
+ sub = ucl_array_set(old, idx + 1, res + 1, nidx - 1,
+ newel, label, oldelp, parentp);
+ ucl_array_append(out, sub);
+ continue;
+ }
+ *oldelp = ucl_render(old);
+ if (newel != NULL)
+ ucl_array_append(out, ucl_object_copy(newel));
+ }
+ ucl_object_iterate_free(it);
+ if (nidx == 1 && newel != NULL && *oldelp == NULL && i == res[0])
+ ucl_array_append(out, ucl_object_copy(newel));
+ return (out);
+}
+
+/*
+ * do_writes() for the ucl target. For each file owning at least one
+ * request: read the text, apply every request to it surgically, prove
+ * with libucl that the result means exactly what was asked, and replace
+ * the file atomically -- or leave it alone when nothing changed. Returns
+ * EXIT_SUCCESS or EXIT_FAILURE.
+ */
+int
+ucl_do_writes(void)
+{
+ struct flat expect;
+ struct flat got;
+ struct request *req;
+ ucl_object_t *top;
+ ucl_object_t *val;
+ ucl_object_t *after;
+ const ucl_object_t *cur;
+ const char *perr;
+ char *newtext;
+ char *newv;
+ char *oldv;
+ char *style;
+ char *text;
+ size_t f;
+ size_t newlen;
+ size_t style_len;
+ size_t textlen;
+ unsigned int n;
+ unsigned int nmine;
+ bool changed;
+ bool dirtied;
+ bool dirty;
+ int rv = EXIT_SUCCESS;
+
+ for (f = 0; f < nconf_files; f++) {
+ nmine = 0;
+ for (n = 0; n < nreqs; n++) {
+ if (reqs[n].remove) {
+ if (reqs[n].infile[f])
+ nmine++;
+ } else if (reqs[n].newvalue != NULL &&
+ ucl_write_target(&reqs[n]) == f)
+ nmine++;
+ }
+ if (nmine == 0)
+ continue;
+
+ text = slurp(conf_files[f], &textlen);
+ top = ucl_parse_text(conf_files[f], text, textlen, NULL);
+ memset(&expect, 0, sizeof(expect));
+ (void)ucl_walk(top, NULL, flat_cb, &expect);
+
+ dirty = false;
+ for (n = 0; n < nreqs; n++) {
+ req = &reqs[n];
+ if (req->ucl_base != NULL) {
+ /*
+ * `name.i...': the innermost array gains,
+ * loses, or changes one element; the file
+ * sees the whole array, the echo the element
+ * (a removal echoes its array, like -=).
+ */
+ const ucl_object_t *parent = NULL;
+ ucl_object_t *newel = NULL;
+ char *newelv;
+ char *plabel;
+
+ if (req->remove ? !req->infile[f] :
+ (req->newvalue == NULL ||
+ ucl_write_target(req) != f))
+ continue;
+ cur = ucl_object_lookup_path(top,
+ req->ucl_base);
+ if (cur == NULL ||
+ ucl_object_type(cur) != UCL_ARRAY) {
+ if (req->remove)
+ continue;
+ errx(EXIT_FAILURE,
+ "%s: no array %s in %s",
+ req->ucl_label, req->ucl_base,
+ conf_files[f]);
+ }
+ if (req->remove)
+ newelv = NULL;
+ else if (req->listop != '\0') {
+ /* Words apply to the element as is */
+ const ucl_object_t *fresh;
+ char *freshv;
+
+ fresh = ucl_array_walk(cur,
+ req->ucl_idx, req->ucl_res,
+ req->ucl_nidx);
+ freshv = (fresh != NULL) ?
+ ucl_render(fresh) : NULL;
+ newel = ucl_list_apply(freshv,
+ req->rawvalue, req->listop);
+ free(freshv);
+ newelv = ucl_render(newel);
+ } else {
+ newel = ucl_value(req->rawvalue != NULL ?
+ req->rawvalue : req->newvalue);
+ newelv = ucl_render(newel);
+ }
+ plabel = ucl_index_label(req,
+ req->ucl_nidx - 1);
+ oldv = NULL;
+ val = ucl_array_set(cur, req->ucl_idx,
+ req->ucl_res, req->ucl_nidx, newel,
+ plabel, &oldv, &parent);
+ if (newel != NULL)
+ ucl_object_unref(newel);
+ if (req->remove && oldv == NULL) {
+ ucl_object_unref(val);
+ free(plabel);
+ continue;
+ }
+ /* The indices as this file resolves them */
+ ucl_relabel(req);
+ free(plabel);
+ plabel = ucl_index_label(req,
+ req->ucl_nidx - 1);
+ if (oldv == NULL && (parent == NULL ||
+ ucl_object_type(parent) != UCL_ARRAY))
+ errx(EXIT_FAILURE, "%s: not an array",
+ plabel);
+ if (oldv == NULL &&
+ req->ucl_res[req->ucl_nidx - 1] !=
+ (long)ucl_array_size(parent))
+ errx(EXIT_FAILURE,
+ "%s: %s has %u element%s",
+ req->ucl_label, plabel,
+ ucl_array_size(parent),
+ ucl_array_size(parent) == 1 ?
+ "" : "s");
+ newv = ucl_render(val);
+ changed = false;
+ if (oldv == NULL || req->remove ||
+ strcmp(oldv, newelv) != 0) {
+ newtext = ucl_text_edit(text, textlen,
+ req->ucl_base, val, newv, NULL, 0,
+ &changed);
+ free(text);
+ text = newtext;
+ textlen = strlen(text);
+ if (changed) {
+ flat_set(&expect,
+ req->ucl_base, newv);
+ req->found = 1;
+ dirty = true;
+ }
+ }
+ if (!quiet) {
+ if (req->remove) {
+ char *pold;
+ char *pnew;
+ const ucl_object_t *pafter;
+ size_t d;
+
+ /* The array that changed */
+ pold = ucl_render(parent);
+ pafter = val;
+ for (d = 0;
+ d + 1 < req->ucl_nidx; d++)
+ pafter =
+ ucl_array_find_index(
+ pafter, (unsigned int)
+ req->ucl_res[d]);
+ pnew = ucl_render(pafter);
+ print_write_echo(conf_files[f],
+ 0, plabel, req->op, pold,
+ pnew, changed, 0, 0);
+ free(pold);
+ free(pnew);
+ } else if (verbose_trail &&
+ oldv == NULL)
+ print_write_echo(conf_files[f],
+ 0, req->ucl_label, req->op,
+ "", newelv, changed, 1, 0);
+ else
+ print_write_echo(conf_files[f],
+ 0, req->ucl_label, req->op,
+ oldv, newelv, changed, 0,
+ 0);
+ }
+ /* `parent' and `cur' die with the old parse */
+ if (changed)
+ top = ucl_reparse(top, conf_files[f],
+ text, textlen);
+ free(newelv);
+ free(plabel);
+ ucl_object_unref(val);
+ free(oldv);
+ free(newv);
+ continue;
+ }
+ if (req->remove) {
+ if (!req->infile[f])
+ continue;
+ /*
+ * A key repeated in the file is removed
+ * every time, lest a later one be unmasked.
+ * Each pass cuts text, so this ends.
+ */
+ dirtied = false;
+ while (ucl_object_lookup_path(top, req->name) !=
+ NULL) {
+ newtext = ucl_text_edit(text, textlen,
+ req->name, NULL, NULL, NULL, 0,
+ &changed);
+ free(text);
+ text = newtext;
+ textlen = strlen(text);
+ if (!changed)
+ break;
+ dirtied = true;
+ top = ucl_reparse(top, conf_files[f],
+ text, textlen);
+ }
+ if (!dirtied)
+ continue;
+ flat_remove(&expect, req->name);
+ req->found = 1;
+ dirty = true;
+ if (!quiet && (verbose || verbose_trail))
+ print_write_echo(conf_files[f], 0,
+ req->name, req->op, "", "", 0, 0,
+ 1);
+ continue;
+ }
+ if (req->newvalue == NULL ||
+ ucl_write_target(req) != f)
+ continue;
+ cur = ucl_object_lookup_path(top, req->name);
+ oldv = (cur != NULL) ? ucl_render(cur) : NULL;
+ if (req->listop != '\0' && oldv != NULL)
+ /* Words apply to the value as it is now */
+ val = ucl_list_apply(oldv, req->rawvalue,
+ req->listop);
+ else if (req->listop != '\0')
+ /* Not in this file: the scan's merge stands */
+ val = ucl_value(req->newvalue);
+ else
+ val = ucl_value(req->rawvalue != NULL ?
+ req->rawvalue : req->newvalue);
+ newv = ucl_render(val);
+ changed = false;
+ if (oldv == NULL || strcmp(oldv, newv) != 0) {
+ /*
+ * A file with nothing to mimic takes its
+ * style from the file that defined the key
+ * or, for pkg's local repos file, from the
+ * base file it overrides.
+ */
+ style = NULL;
+ style_len = 0;
+ if (req->srcidx >= 0 &&
+ (size_t)req->srcidx != f)
+ style = slurp(conf_files[req->srcidx],
+ &style_len);
+ else if (pkg_target && f == pkg_repos_idx &&
+ conf_ro != NULL) {
+ size_t s;
+
+ for (s = 0; s < nconf_files; s++)
+ if (conf_ro[s])
+ break;
+ if (s < nconf_files)
+ style = slurp(conf_files[s],
+ &style_len);
+ }
+ newtext = ucl_text_edit(text, textlen,
+ req->name, val, req->listop != '\0' ?
+ newv : req->rawvalue != NULL ?
+ req->rawvalue : req->newvalue, style,
+ style_len, &changed);
+ free(style);
+ free(text);
+ text = newtext;
+ textlen = strlen(text);
+ if (changed) {
+ top = ucl_reparse(top, conf_files[f],
+ text, textlen);
+ flat_remove(&expect, req->name);
+ if (ucl_object_type(val) == UCL_OBJECT)
+ (void)ucl_walk(val, req->name,
+ flat_cb, &expect);
+ else
+ flat_set(&expect, req->name,
+ newv);
+ dirty = true;
+ }
+ }
+ ucl_object_unref(val);
+ /*
+ * Echo against the value as it stood before this
+ * edit, or, when the key is not in this file, the
+ * effective value the scan found (possibly in an
+ * earlier, read-only file); `added' and `changed'
+ * describe this file.
+ */
+ if (!quiet) {
+ if (verbose_trail && oldv == NULL)
+ print_write_echo(conf_files[f], 0,
+ req->name, req->op, "", newv,
+ changed, 1, 0);
+ else
+ print_write_echo(conf_files[f], 0,
+ req->name, req->op, oldv != NULL ?
+ oldv : req->value, newv, changed,
+ 0, 0);
+ }
+ free(oldv);
+ free(newv);
+ }
+
+ if (dirty) {
+ /* The library has the last word on what we wrote */
+ after = ucl_parse_text(conf_files[f], text, textlen,
+ &perr);
+ if (after == NULL)
+ errx(EXIT_FAILURE,
+ "%s: edit would not parse (%s); "
+ "file left unchanged", conf_files[f],
+ perr != NULL ? perr : "unknown error");
+ memset(&got, 0, sizeof(got));
+ (void)ucl_walk(after, NULL, flat_cb, &got);
+ if (!flat_equal(&expect, &got))
+ errx(EXIT_FAILURE,
+ "%s: edit would not mean what was asked; "
+ "file left unchanged", conf_files[f]);
+ flat_free(&got);
+ ucl_object_unref(after);
+ newlen = textlen;
+ ucl_replace_file(conf_files[f], text, newlen);
+ }
+ flat_free(&expect);
+ ucl_object_unref(top);
+ free(text);
+ }
+
+ /* A removal that matched no file at all is an unknown directive */
+ for (n = 0; n < nreqs; n++) {
+ if (!reqs[n].remove || reqs[n].found)
+ continue;
+ if (ignore_unknown)
+ continue;
+ if (!quiet)
+ warnx("unknown directive '%s'", reqs[n].name);
+ rv = EXIT_FAILURE;
+ }
+
+ return (rv);
+}
diff --git a/usr.sbin/sysconf/tests/sysconf_test.sh b/usr.sbin/sysconf/tests/sysconf_test.sh
--- a/usr.sbin/sysconf/tests/sysconf_test.sh
+++ b/usr.sbin/sysconf/tests/sysconf_test.sh
@@ -472,6 +472,12 @@
-e match:'src' \
-e match:'sysctl' \
"$SYSCONF" s foo
+ # Ambiguous: pkg and poudriere (`po' is the poudriere shorthand)
+ atf_check -s not-exit:0 \
+ -e match:'ambiguous target' \
+ -e match:'pkg' \
+ -e match:'poudriere' \
+ "$SYSCONF" p foo
# Exact still wins
atf_check -o inline:'foo: 1\n' "$SYSCONF" src -R "$ROOT" foo
# Infix is not a prefix
@@ -506,28 +512,28 @@
# -l / -L are printed here, one path per line (not sysrc -L)
atf_check -o inline:"$ETC/poudriere.conf\n" \
- "$SYSCONF" p -l
+ "$SYSCONF" po -l
atf_check -o inline:"$ETC/poudriere.conf\n$ETC/poudriere.d/poudriere.conf\n" \
- "$SYSCONF" p -L
+ "$SYSCONF" po -L
atf_check -o inline:"$ETC/poudriere.conf\n$ETC/poudriere.d/131amd64-poudriere.conf\n" \
- "$SYSCONF" p --jail 131amd64 -l
+ "$SYSCONF" po --jail 131amd64 -l
atf_check -o inline:"$ETC/poudriere.conf\n$ETC/poudriere.d/poudriere.conf\n$ETC/poudriere.d/131amd64-poudriere.conf\n" \
- "$SYSCONF" p --jail 131amd64 -L
+ "$SYSCONF" po --jail 131amd64 -L
atf_check -o inline:"$ETC/poudriere.conf\n" \
- "$SYSCONF" p --jail 99amd64 -l
+ "$SYSCONF" po --jail 99amd64 -l
atf_check -o match:'99amd64-poudriere.conf' \
-o not-match:'rc\.conf' \
- "$SYSCONF" p --jail 99amd64 -L
+ "$SYSCONF" po --jail 99amd64 -L
atf_check -o inline:"$ETC/poudriere.conf\n" \
- "$SYSCONF" p -E -L
+ "$SYSCONF" po -E -L
atf_check -o inline:"$ETC/poudriere.conf\n$ETC/poudriere.d/131amd64-poudriere.conf\n" \
- "$SYSCONF" p --jail 131amd64 -E -L
+ "$SYSCONF" po --jail 131amd64 -E -L
atf_check -s not-exit:0 -e match:'take no names' \
- "$SYSCONF" p -l ZPOOL
+ "$SYSCONF" po -l ZPOOL
- # Shorthand p; read from the global file
+ # Shorthand po; `p' is ambiguous with pkg
atf_check -o match:'ZPOOL: old' \
- "$SYSCONF" p ZPOOL
+ "$SYSCONF" po ZPOOL
# Write a new name into the first file
atf_check -o match:'BASEFS' \
"$SYSCONF" poudriere BASEFS=/poudriere
@@ -561,13 +567,13 @@
printf 'src=from-conf\nm=short\n' >> "$ETC/poudriere.conf"
printf 'src=from-overlay\n' > "$ETC/poudriere.d/src.conf"
atf_check -o match:'src: from-conf' \
- "$SYSCONF" p -- src
+ "$SYSCONF" po -- src
atf_check -o match:'m: short' \
- "$SYSCONF" p -- m
+ "$SYSCONF" po -- m
atf_check -s not-exit:0 -e match:"unknown variable 'make'" \
- "$SYSCONF" p -- make
+ "$SYSCONF" po -- make
atf_check -o match:'src: from-overlay' \
- "$SYSCONF" p src src
+ "$SYSCONF" po src src
# -f replaces discovery
printf 'BAR=only\n' > "$ETC/other.conf"
@@ -579,7 +585,7 @@
export POUDRIERE_ETC="$( pwd )/missing-etc"
mkdir -p "$POUDRIERE_ETC"
atf_check -s not-exit:0 -e match:'poudriere.conf' \
- "$SYSCONF" p ZPOOL
+ "$SYSCONF" po ZPOOL
}
poudriere_sysrc_cleanup()
{
@@ -608,11 +614,11 @@
# Shorthand p m
atf_check -o inline:'CFLAGS: global\n' \
- "$SYSCONF" p m CFLAGS
+ "$SYSCONF" po m CFLAGS
atf_check -o inline:'CFLAGS: jail\n' \
"$SYSCONF" poudriere make --jail 131amd64 CFLAGS
atf_check -o inline:'CFLAGS: tree\n' \
- "$SYSCONF" p m --tree default CFLAGS
+ "$SYSCONF" po m --tree default CFLAGS
atf_check -o inline:'CFLAGS: tree\n' \
"$SYSCONF" poudriere make --ports default CFLAGS
atf_check -o inline:'CFLAGS: set\n' \
@@ -634,7 +640,7 @@
# Create missing global
rm -f "$D/make.conf"
atf_check -o match:'LDFLAGS' \
- "$SYSCONF" p m LDFLAGS=-L/usr/local/lib
+ "$SYSCONF" po m LDFLAGS=-L/usr/local/lib
atf_check -o match:'LDFLAGS=-L/usr/local/lib' cat "$D/make.conf"
# src vs src-env; --tree ignored (no tree-src.conf in poudriere(8))
@@ -651,12 +657,12 @@
atf_check -o inline:'SRC_ENV_VAR: 1\n' \
"$SYSCONF" poudriere src-env --tree default SRC_ENV_VAR
atf_check -o inline:'SRC_ENV_VAR: 1\n' \
- "$SYSCONF" p src-e SRC_ENV_VAR
+ "$SYSCONF" po src-e SRC_ENV_VAR
# Ambiguous nested prefix
atf_check -s not-exit:0 -e match:'ambiguous' -e match:'src' \
-e match:'src-env' \
- "$SYSCONF" p s FOO
+ "$SYSCONF" po s FOO
# -l lists the collection
atf_check -o match:'make.conf' -o match:'131amd64-make.conf' \
@@ -664,27 +670,503 @@
# -L lists every poudriere(8) stem, including files not on disk
atf_check -o inline:"$D/make.conf\n$D/abc-make.conf\n$D/xyz-make.conf\n$D/160-make.conf\n$D/xyz-abc-make.conf\n$D/160-xyz-make.conf\n$D/160-abc-make.conf\n$D/160-xyz-abc-make.conf\n" \
- "$SYSCONF" p m --jail 160 --set abc --tree xyz -L
+ "$SYSCONF" po m --jail 160 --set abc --tree xyz -L
atf_check -o inline:"$D/make.conf\n$D/xyz-make.conf\n$D/160-make.conf\n$D/160-xyz-make.conf\n" \
- "$SYSCONF" p m --jail 160 --tree xyz -L
+ "$SYSCONF" po m --jail 160 --tree xyz -L
atf_check -o inline:"$D/make.conf\n" \
- "$SYSCONF" p m --jail 160 --set abc --tree xyz -l
+ "$SYSCONF" po m --jail 160 --set abc --tree xyz -l
atf_check -o inline:"$D/make.conf\n" \
- "$SYSCONF" p m --jail 160 --set abc --tree xyz -E -L
+ "$SYSCONF" po m --jail 160 --set abc --tree xyz -E -L
atf_check -o inline:"$D/src.conf\n$D/abc-src.conf\n$D/160-src.conf\n" \
- "$SYSCONF" p src --jail 160 --set abc --tree xyz -L
+ "$SYSCONF" po src --jail 160 --set abc --tree xyz -L
atf_check -o inline:"$D/src.conf\n$D/160-src.conf\n" \
- "$SYSCONF" p src --jail 160 --tree xyz -L
+ "$SYSCONF" po src --jail 160 --tree xyz -L
atf_check -o inline:"$D/src-env.conf\n$D/abc-src-env.conf\n$D/160-src-env.conf\n" \
- "$SYSCONF" p src-env --jail 160 --set abc --tree xyz -L
+ "$SYSCONF" po src-env --jail 160 --set abc --tree xyz -L
atf_check -o inline:"$D/src-env.conf\n" \
- "$SYSCONF" p src-env --jail 160 --set abc --tree xyz -E -L
+ "$SYSCONF" po src-env --jail 160 --set abc --tree xyz -E -L
}
poudriere_overlays_cleanup()
{
rm -rf "$( pwd )/petc"
}
+atf_test_case ucl_groups cleanup
+ucl_groups_head()
+{
+ atf_set "descr" \
+ "UCL via libucl: dotted paths, typed values, surgical edits"
+}
+ucl_groups_body()
+{
+ setup_root
+ cat > "$ROOT/app.conf" <<'EOF'
+# sample
+FreeBSD-ports: {
+ url: "pkg+https://pkg.FreeBSD.org/${ABI}/latest",
+ enabled: yes
+}
+FreeBSD-base: { enabled: no }
+ALIAS {
+ all-depends = "query %dn-%dv";
+}
+PLUGINS [
+ provides
+]
+EOF
+ cp "$ROOT/app.conf" "$ROOT/app.orig"
+ # -f is the caller's path; do not also pass -R (that prefixes it)
+ # Booleans read back as the library renders them
+ atf_check -o inline:'true\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -n FreeBSD-ports.enabled
+ # No variables are defined: ${ABI} is literal
+ atf_check -o inline:'pkg+https://pkg.FreeBSD.org/${ABI}/latest\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -n FreeBSD-ports.url
+ atf_check -o inline:'false\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -n FreeBSD-base.enabled
+ atf_check -o inline:'query %dn-%dv\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -n ALIAS.all-depends
+ # Arrays are leaves, shown as compact JSON
+ atf_check -o inline:'["provides"]\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -n PLUGINS
+ # -c compares in the same rendering
+ atf_check "$SYSCONF" ucl -f "$ROOT/app.conf" -c FreeBSD-ports.enabled=yes
+ atf_check -s exit:1 -e match:'differs' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -c FreeBSD-ports.enabled=no
+ # Rewrite one value in place: only that line changes, and the
+ # boolean keeps the file's spelling
+ atf_check -o inline:'FreeBSD-ports.enabled: true -> false\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" FreeBSD-ports.enabled=no
+ atf_check -o inline:'4c4\n< enabled: yes\n---\n> enabled: no\n' \
+ -s exit:1 diff "$ROOT/app.orig" "$ROOT/app.conf"
+ # Unchanged write leaves the file alone
+ cp "$ROOT/app.conf" "$ROOT/app.before"
+ atf_check -o inline:'FreeBSD-ports.enabled: false (unchanged)\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" FreeBSD-ports.enabled=no
+ atf_check cmp -s "$ROOT/app.conf" "$ROOT/app.before"
+ # New key joins a one-line object in its style
+ atf_check -o inline:'FreeBSD-base.priority: -> 10\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" FreeBSD-base.priority=10
+ atf_check -o match:'^FreeBSD-base: \{ enabled: no, priority: 10 \}$' \
+ cat "$ROOT/app.conf"
+ # New nested path creates the intermediate objects, file style
+ atf_check -o inline:'Other.inner.enabled: -> true\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" Other.inner.enabled=yes
+ atf_check -o inline:'true\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -n Other.inner.enabled
+ atf_check -o inline:'Other: {\n inner: {\n enabled: yes\n }\n}\n' \
+ tail -5 "$ROOT/app.conf"
+ # A value the grammar would cut short is kept whole, quoted
+ atf_check -o inline:'Other.note: -> a#b\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" Other.note=a#b
+ atf_check -o inline:'a#b\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -n Other.note
+ atf_check -o match:'note: "a#b"' cat "$ROOT/app.conf"
+ # Writing through a scalar is refused
+ atf_check -s exit:1 -e match:'not an object' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" Other.note.x=1
+ # += and -= on an array add and strike elements, one line each in
+ # the array's own style; the file is never handed a word list
+ atf_check -o inline:'PLUGINS: ["provides"] -> ["provides","bar"]\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS+=bar
+ atf_check -o inline:'PLUGINS [\n\tprovides\n\tbar\n]\n' \
+ sed -n '/^PLUGINS/,/^]/p' "$ROOT/app.conf"
+ atf_check -o inline:'PLUGINS: ["provides","bar"] (unchanged)\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS+=bar
+ atf_check "$SYSCONF" ucl -f "$ROOT/app.conf" -c PLUGINS+=bar
+ # A comment trailing the struck element goes with its line, as a
+ # sysrc(8) removal would take it; one on a kept element stays put
+ sed -i '' -e 's/^ bar$/& # gone/' -e 's/^ provides$/& # kept/' \
+ "$ROOT/app.conf"
+ atf_check -o inline:'PLUGINS: ["provides","bar"] -> ["provides"]\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS-=bar
+ atf_check -o inline:'PLUGINS [\n\tprovides\t# kept\n]\n' \
+ sed -n '/^PLUGINS/,/^]/p' "$ROOT/app.conf"
+ sed -i '' -e 's/^ provides # kept$/ provides/' "$ROOT/app.conf"
+ # Under an array a path component is an index (from zero) and `#'
+ # the count, as uclcmd(1) spells it; name.i shows the request as
+ # typed, name.# reads under the array's name; past the end is unknown
+ atf_check -o inline:'PLUGINS: 1\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" 'PLUGINS.#'
+ atf_check -o inline:'PLUGINS.0: provides\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.0
+ atf_check -s exit:1 -e inline:"sysconf: unknown directive 'PLUGINS.1'\n" \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.1
+ atf_check -s exit:1 -e inline:"sysconf: unknown directive 'PLUGINS.x'\n" \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.x
+ # name.i= changes that element where it stands; one past the end
+ # appends; -x name.i strikes it and echoes the array, like -=
+ atf_check -o inline:'PLUGINS.0: provides -> quux\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.0=quux
+ atf_check -o inline:'PLUGINS.1: -> zed\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.1=zed
+ atf_check -o inline:'PLUGINS [\n\tquux\n\tzed\n]\n' \
+ sed -n '/^PLUGINS/,/^]/p' "$ROOT/app.conf"
+ atf_check -s exit:1 -e match:'PLUGINS has 2 elements' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.5=far
+ atf_check "$SYSCONF" ucl -f "$ROOT/app.conf" -c PLUGINS.1=zed
+ atf_check -s exit:1 -e match:'differs' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -c PLUGINS.1=bar
+ atf_check -o inline:'PLUGINS: ["quux","zed"] -> ["quux"]\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -x PLUGINS.1
+ atf_check -o inline:'PLUGINS.0: quux -> provides\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.0=provides
+ # name.#=value appends (the echo names the index taken); a negative
+ # index counts from the end; neither removes or edits the count
+ atf_check -o inline:'PLUGINS.1: -> foo\nPLUGINS.2: -> bar\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" 'PLUGINS.#=foo' 'PLUGINS.#=bar'
+ atf_check -o inline:'PLUGINS.2: bar\nPLUGINS.0: provides\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.-1 PLUGINS.-3
+ atf_check -s exit:1 -e inline:"sysconf: unknown directive 'PLUGINS.-4'\n" \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.-4
+ atf_check -o inline:'PLUGINS.2: bar -> baz\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.-1=baz
+ atf_check -o inline:'PLUGINS: ["provides","foo","baz"] -> ["provides","foo"]\nPLUGINS: ["provides","foo"] -> ["provides"]\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -x PLUGINS.-1 PLUGINS.-1
+ atf_check -s exit:1 -e match:'count cannot be removed' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -x 'PLUGINS.#'
+ atf_check -s exit:1 -e match:'count cannot be edited' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" 'PLUGINS.#+=x'
+ # Striking the last word leaves an empty string, not null
+ atf_check -o inline:'PLUGINS.0: provides -> \n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.-1-=provides
+ atf_check -o inline:'PLUGINS: [""]\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS
+ atf_check -o inline:'PLUGINS.0: -> provides\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.-1+=provides
+ # Under an object the same components are keys
+ printf 'errors { "404" = "Not Found", "#" = 7 }\n' > "$ROOT/k.conf"
+ atf_check -o inline:'errors.404: Not Found\nerrors.#: 7\n' \
+ "$SYSCONF" ucl -f "$ROOT/k.conf" errors.404 'errors.#'
+ # Keys the library cannot take bare (`*', `[1m') are written
+ # quoted; a file that leaves `*' bare (as uclcmd(1) may) is
+ # still read, and does not hide its neighbours
+ atf_check -o inline:'foo.*: -> all\nfoo.**: -> allall\nfoo.[1m: -> bold\nfoo.[7m: -> inverse\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" 'foo.*=all' 'foo.**=allall' \
+ 'foo.[1m=bold' 'foo.[7m=inverse'
+ atf_check -o match:'"\*"' -o match:'"\*\*"' \
+ -o match:'"\[1m"' -o match:'"\[7m"' \
+ cat "$ROOT/app.conf"
+ atf_check -o inline:'foo.*: all\nfoo.[1m: bold\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" 'foo.*' 'foo.[1m'
+ # uclcmd(1) may emit `*' and `**' bare; the quoted neighbours stay
+ sed -i '' -e 's/"\*"/ */' -e 's/"\*\*"/ **/ ' "$ROOT/app.conf"
+ atf_check -o inline:'foo.*: all\nfoo.**: allall\nfoo.[1m: bold\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" 'foo.*' 'foo.**' 'foo.[1m'
+ atf_check -o inline:'foo.*: all -> all-pattern\nfoo.[1m: bold -> bolden\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" 'foo.*=all-pattern' \
+ 'foo.[1m=bolden'
+ "$SYSCONF" ucl -f "$ROOT/app.conf" -x 'foo.*' 'foo.**' 'foo.[1m' \
+ 'foo.[7m'
+ # name.i+= and name.i-= edit the element: words on a string,
+ # elements on a nested array; indices chain through nested arrays
+ atf_check -o inline:'PLUGINS.0: provides -> provides x\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.0+=x
+ atf_check -o inline:'PLUGINS [\n\t"provides x"\n]\n' \
+ sed -n '/^PLUGINS/,/^]/p' "$ROOT/app.conf"
+ atf_check -o inline:'PLUGINS.0: provides x -> provides\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS.0-=x
+ printf 'M [\n [ 1, 2 ],\n [\n 3,\n 4\n ]\n]\n' > "$ROOT/m.conf"
+ atf_check -o inline:'M: 2\nM.1: 2\nM.1.0: 3\nM.1.1: 4\n' \
+ "$SYSCONF" ucl -f "$ROOT/m.conf" 'M.#' 'M.1.#' M.1.0 M.-1.-1
+ atf_check -s exit:1 -e match:'M.1.0: not an array' \
+ "$SYSCONF" ucl -f "$ROOT/m.conf" M.1.0.0
+ atf_check -s exit:1 -e inline:"sysconf: unknown directive 'M.2.0'\n" \
+ "$SYSCONF" ucl -f "$ROOT/m.conf" M.2.0
+ # Requests in one run see one another's edits; each nested array
+ # keeps its own layout
+ atf_check -o inline:'M.1.0: 3 -> 30\nM.1: [30,4] -> [30,4,5]\nM.0: [1,2] -> [2]\n' \
+ "$SYSCONF" ucl -f "$ROOT/m.conf" M.1.0=30 M.1+=5 M.0-=1
+ atf_check -o inline:'M [\n [ 2 ],\n [\n 30,\n 4,\n 5\n ]\n]\n' \
+ cat "$ROOT/m.conf"
+ atf_check -o inline:'M.1: [30,4,5] -> [30,4]\n' \
+ "$SYSCONF" ucl -f "$ROOT/m.conf" -x M.1.2
+ atf_check -o inline:'M.1.0: 30 -> 3\nM.0: [2] -> [2,1]\n' \
+ "$SYSCONF" ucl -f "$ROOT/m.conf" M.1.0=3 M.0+=1
+ atf_check -o inline:'M [\n [ 2, 1 ],\n [\n 3,\n 4\n ]\n]\n' \
+ cat "$ROOT/m.conf"
+ atf_check -o inline:'PLUGINS: ["provides"] -> ["provides","a"]\nPLUGINS: ["provides","a"] -> ["provides","a","b"]\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS+=a PLUGINS+=b
+ atf_check -o inline:'PLUGINS: ["provides","a","b"] -> ["provides","b"]\nPLUGINS: ["provides","b"] -> ["provides"]\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" PLUGINS-=a PLUGINS-=b
+ # A string keeps the sysrc(8) word edit
+ atf_check -o inline:'ALIAS.all-depends: query %dn-%dv -> query %dn-%dv -x\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" ALIAS.all-depends+=-x
+ atf_check -o match:'all-depends = "query %dn-%dv -x";' \
+ cat "$ROOT/app.conf"
+ atf_check -o inline:'ALIAS.all-depends: query %dn-%dv -x -> query %dn-%dv\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" ALIAS.all-depends-=-x
+ # Removals undo the additions exactly; an emptied object goes too,
+ # and so does a comment trailing a removed statement
+ sed -i '' -e 's/^ note: "a#b",\{0,1\}$/& # scratch/' "$ROOT/app.conf"
+ atf_check -o match:'# scratch' cat "$ROOT/app.conf"
+ atf_check "$SYSCONF" ucl -f "$ROOT/app.conf" -x FreeBSD-base.priority \
+ Other.inner.enabled Other.note
+ atf_check -o not-match:'scratch' cat "$ROOT/app.conf"
+ atf_check -o inline:'FreeBSD-ports.enabled: false -> true\n' \
+ "$SYSCONF" ucl -f "$ROOT/app.conf" FreeBSD-ports.enabled=yes
+ atf_check cmp -s "$ROOT/app.orig" "$ROOT/app.conf"
+ # No default files
+ atf_check -s exit:1 -e match:'no default files' "$SYSCONF" ucl foo
+}
+ucl_groups_cleanup()
+{
+ cleanup_root
+}
+
+atf_test_case ucl_hostile_input cleanup
+ucl_hostile_input_head()
+{
+ atf_set "descr" \
+ "UCL: null characters, stray statements and wild indices"
+}
+ucl_hostile_input_body()
+{
+ setup_root
+ # A null character is refused on read and write; the file is left alone
+ printf '\000' > "$ROOT/nul.conf"
+ printf 'a = 1;\n\000b = 2;\n' > "$ROOT/mid.conf"
+ cp "$ROOT/mid.conf" "$ROOT/mid.orig"
+ atf_check -s exit:1 -e match:'contains a null character' \
+ "$SYSCONF" ucl -f "$ROOT/nul.conf" a
+ atf_check -s exit:1 -e match:'contains a null character' \
+ "$SYSCONF" ucl -f "$ROOT/mid.conf" a
+ atf_check -s exit:1 -e match:'contains a null character' \
+ "$SYSCONF" ucl -f "$ROOT/mid.conf" a=3
+ atf_check cmp -s "$ROOT/mid.orig" "$ROOT/mid.conf"
+ # Typed text that could open a macro lands as one quoted string
+ printf 'a = 1;\n' > "$ROOT/m.conf"
+ atf_check -o ignore "$SYSCONF" ucl -f "$ROOT/m.conf" \
+ "a=2
+.include \"$ROOT/evil.conf\""
+ atf_check -o not-match:'^\.include' cat "$ROOT/m.conf"
+ atf_check -o ignore "$SYSCONF" ucl -f "$ROOT/m.conf" \
+ "a=2; .include \"$ROOT/evil.conf\""
+ atf_check -o match:'^a = "2; \.include ' cat "$ROOT/m.conf"
+ atf_check -o ignore "$SYSCONF" ucl -f "$ROOT/m.conf" \
+ "a={ b = 1; .include \"$ROOT/evil.conf\" }"
+ atf_check -o match:'^a = "\{ b = 1; \.include ' cat "$ROOT/m.conf"
+ # A plain number is still written as typed
+ atf_check -o ignore "$SYSCONF" ucl -f "$ROOT/m.conf" a=1.5
+ atf_check -o inline:'a = 1.5;\n' cat "$ROOT/m.conf"
+ # A second statement after a newline stays inside the string
+ atf_check -o ignore "$SYSCONF" ucl -f "$ROOT/m.conf" \
+ "a=1
+a = 5"
+ atf_check -o inline:'1\n' grep -c '^a' "$ROOT/m.conf"
+ atf_check -o match:'^a = "1\\na = 5";$' cat "$ROOT/m.conf"
+ # A comment typed after a number cannot hide a neighbour
+ printf 'a = 1; b = 2;\n' > "$ROOT/m.conf"
+ atf_check -o ignore "$SYSCONF" ucl -f "$ROOT/m.conf" 'a=3 # x'
+ atf_check -o inline:'3 # x\n2\n' \
+ "$SYSCONF" ucl -f "$ROOT/m.conf" -n a b
+ # An index libucl cannot count to, or one that would pass for the
+ # count, is a key and so unknown, not an element
+ printf 'L [ x, y ]\n' > "$ROOT/l.conf"
+ for i in 4294967296 4294967297 -4294967296 -9223372036854775808 \
+ -9223372036854775807 9223372036854775808; do
+ atf_check -s exit:1 \
+ -e inline:"sysconf: unknown directive 'L.$i'\n" \
+ "$SYSCONF" ucl -f "$ROOT/l.conf" "L.$i"
+ done
+ # The largest index libucl can count to is merely past the end
+ atf_check -s exit:1 \
+ -e inline:"sysconf: unknown directive 'L.4294967295'\n" \
+ "$SYSCONF" ucl -f "$ROOT/l.conf" L.4294967295
+}
+ucl_hostile_input_cleanup()
+{
+ cleanup_root
+}
+
+atf_test_case ucl_file_shapes cleanup
+ucl_file_shapes_head()
+{
+ atf_set "descr" \
+ "UCL: repeated keys, nested comments, outer braces, bad files"
+}
+ucl_file_shapes_body()
+{
+ setup_root
+ # A repeated key reads as its first; a removal takes every one,
+ # lest a later one surface
+ printf 'foo = 1;\nbar = 2;\nfoo = 3;\n' > "$ROOT/dup.conf"
+ atf_check -o inline:'1\n' "$SYSCONF" ucl -f "$ROOT/dup.conf" -n foo
+ atf_check "$SYSCONF" ucl -f "$ROOT/dup.conf" -x foo
+ atf_check -o inline:'bar = 2;\n' cat "$ROOT/dup.conf"
+ # Block comments nest: the key inside one is not the key
+ printf '/* old /* note */ foo = 1 */\nfoo = 2;\n' > "$ROOT/c.conf"
+ atf_check -o inline:'foo: 2 -> 5\n' \
+ "$SYSCONF" ucl -f "$ROOT/c.conf" foo=5
+ atf_check -o inline:'/* old /* note */ foo = 1 */\nfoo = 5;\n' \
+ cat "$ROOT/c.conf"
+ # A file that is one empty outer object takes the key inside it
+ for body in '{}' '{\n}'; do
+ printf '%b\n' "$body" > "$ROOT/o.conf"
+ atf_check -o inline:'key: -> 1\n' \
+ "$SYSCONF" ucl -f "$ROOT/o.conf" key=1
+ atf_check -o inline:'1\n' \
+ "$SYSCONF" ucl -f "$ROOT/o.conf" -n key
+ atf_check -o match:'^\{' -o not-match:'^"?key' \
+ cat "$ROOT/o.conf"
+ done
+ # A file libucl rejects is fatal, on a read and on a write
+ printf 'a = 1;\nb = {\n' > "$ROOT/bad.conf"
+ cp "$ROOT/bad.conf" "$ROOT/bad.orig"
+ atf_check -s exit:1 -e match:'bad\.conf: ' \
+ "$SYSCONF" ucl -f "$ROOT/bad.conf" a
+ atf_check -s exit:1 -e match:'bad\.conf: ' \
+ "$SYSCONF" ucl -f "$ROOT/bad.conf" a=2
+ atf_check cmp -s "$ROOT/bad.orig" "$ROOT/bad.conf"
+}
+ucl_file_shapes_cleanup()
+{
+ cleanup_root
+}
+
+atf_test_case ucl_replace cleanup
+ucl_replace_head()
+{
+ atf_set "descr" \
+ "UCL: writes follow links, keep the mode, and refuse a FIFO"
+}
+ucl_replace_body()
+{
+ setup_root
+ printf 'a = 1;\n' > "$ROOT/real.conf"
+ chmod 640 "$ROOT/real.conf"
+ ln -s real.conf "$ROOT/link.conf"
+ atf_check -o inline:'a: 1 -> 2\n' \
+ "$SYSCONF" ucl -f "$ROOT/link.conf" a=2
+ atf_check test -L "$ROOT/link.conf"
+ atf_check -o inline:'a = 2;\n' cat "$ROOT/real.conf"
+ atf_check -o inline:'640\n' stat -f %Lp "$ROOT/real.conf"
+ # Nothing is left behind beside it
+ atf_check -o inline:'boot\netc\nlink.conf\nreal.conf\n' ls "$ROOT"
+ # Only a regular file is replaced. A writer feeds the reads, which
+ # would otherwise block opening the FIFO, and is gone before any
+ # check can fail and leave it behind
+ mkfifo "$ROOT/fifo.conf"
+ while :; do : > "$ROOT/fifo.conf"; done >/dev/null 2>&1 &
+ writer=$!
+ rv=0
+ "$SYSCONF" ucl -f "$ROOT/fifo.conf" a=1 2> "$ROOT/err" || rv=$?
+ kill "$writer"
+ atf_check_equal 1 "$rv"
+ atf_check -o inline:"sysconf: $ROOT/fifo.conf: Invalid argument\n" \
+ cat "$ROOT/err"
+ atf_check test -p "$ROOT/fifo.conf"
+}
+ucl_replace_cleanup()
+{
+ cleanup_root
+}
+
+atf_test_case ucl_replace_owner cleanup
+ucl_replace_owner_head()
+{
+ atf_set "descr" "UCL: a write keeps the file's owner and group"
+ atf_set "require.user" "root"
+}
+ucl_replace_owner_body()
+{
+ setup_root
+ printf 'a = 1;\n' > "$ROOT/o.conf"
+ chown nobody:nobody "$ROOT/o.conf"
+ atf_check -o inline:'a: 1 -> 2\n' "$SYSCONF" ucl -f "$ROOT/o.conf" a=2
+ atf_check -o inline:'nobody:nobody\n' \
+ stat -f %Su:%Sg "$ROOT/o.conf"
+}
+ucl_replace_owner_cleanup()
+{
+ cleanup_root
+}
+
+atf_test_case pkg_repos_override cleanup
+pkg_repos_override_head()
+{
+ atf_set "descr" \
+ "pkg reads /etc/pkg and writes overrides under repos/"
+}
+pkg_repos_override_body()
+{
+ setup_root
+ mkdir -p "$ROOT/etc/pkg" "$ROOT/usr/local/etc/pkg/repos"
+ cat > "$ROOT/etc/pkg/FreeBSD.conf" <<'EOF'
+# base definitions; override under /usr/local/etc/pkg/repos
+FreeBSD-ports: {
+ url: "pkg+https://pkg.FreeBSD.org/${ABI}/latest",
+ enabled: yes
+}
+FreeBSD-base: {
+ enabled: no
+}
+EOF
+ cp "$ROOT/etc/pkg/FreeBSD.conf" "$ROOT/base.before"
+ printf '# local\nPKG_ENABLE_PLUGINS: true\n' \
+ > "$ROOT/usr/local/etc/pkg.conf"
+ atf_check -o inline:'true\n' \
+ "$SYSCONF" pkg -R "$ROOT" -n FreeBSD-ports.enabled
+ atf_check -o inline:'true\n' \
+ "$SYSCONF" pkg -R "$ROOT" -n PKG_ENABLE_PLUGINS
+ # -F names the base file; -l lists the repos file before it exists
+ atf_check -o match:'/etc/pkg/FreeBSD.conf$' \
+ "$SYSCONF" pkg -R "$ROOT" -n -F FreeBSD-ports.enabled
+ atf_check -o match:'/usr/local/etc/pkg/repos/FreeBSD.conf$' \
+ "$SYSCONF" pkg -R "$ROOT" -l
+ # Base file is not the write target: the repos file is created
+ # in the base file's style
+ atf_check -o inline:'FreeBSD-ports.enabled: true -> false\n' \
+ "$SYSCONF" pkg -R "$ROOT" FreeBSD-ports.enabled=no
+ atf_check cmp -s "$ROOT/etc/pkg/FreeBSD.conf" "$ROOT/base.before"
+ atf_check -o inline:'FreeBSD-ports: {\n enabled: no\n}\n' \
+ cat "$ROOT/usr/local/etc/pkg/repos/FreeBSD.conf"
+ atf_check -o inline:'false\n' \
+ "$SYSCONF" pkg -R "$ROOT" -n FreeBSD-ports.enabled
+ atf_check -o match:'/usr/local/etc/pkg/repos/FreeBSD.conf$' \
+ "$SYSCONF" pkg -R "$ROOT" -n -F FreeBSD-ports.enabled
+ # url still comes from the base file, ${ABI} unexpanded
+ atf_check -o inline:'pkg+https://pkg.FreeBSD.org/${ABI}/latest\n' \
+ "$SYSCONF" pkg -R "$ROOT" -n FreeBSD-ports.url
+ # Second write updates the local key in place (no second object)
+ atf_check -o inline:'FreeBSD-ports.enabled: false -> true\n' \
+ "$SYSCONF" pkg -R "$ROOT" FreeBSD-ports.enabled=yes
+ atf_check -o inline:'FreeBSD-ports: {\n enabled: yes\n}\n' \
+ cat "$ROOT/usr/local/etc/pkg/repos/FreeBSD.conf"
+ # A second repository key joins the object, base-file punctuation
+ atf_check -o inline:'FreeBSD-ports.priority: -> 5\n' \
+ "$SYSCONF" pkg -R "$ROOT" FreeBSD-ports.priority=5
+ atf_check -o inline:'FreeBSD-ports: {\n enabled: yes,\n priority: 5\n}\n' \
+ cat "$ROOT/usr/local/etc/pkg/repos/FreeBSD.conf"
+ atf_check "$SYSCONF" pkg -R "$ROOT" -x FreeBSD-ports.priority
+ atf_check -o inline:'FreeBSD-ports: {\n enabled: yes\n}\n' \
+ cat "$ROOT/usr/local/etc/pkg/repos/FreeBSD.conf"
+ # Global key lands in pkg.conf; the rest of the file is untouched
+ atf_check -o inline:'ASSUME_ALWAYS_YES: -> true\n' \
+ "$SYSCONF" pkg -R "$ROOT" ASSUME_ALWAYS_YES=yes
+ atf_check -o inline:'# local\nPKG_ENABLE_PLUGINS: true\nASSUME_ALWAYS_YES: yes\n' \
+ cat "$ROOT/usr/local/etc/pkg.conf"
+ atf_check -o not-match:'ASSUME_ALWAYS_YES' \
+ cat "$ROOT/usr/local/etc/pkg/repos/FreeBSD.conf"
+ # A key defined only in the base file cannot be removed
+ atf_check -s exit:1 -e match:'unknown directive' \
+ "$SYSCONF" pkg -R "$ROOT" -x FreeBSD-base.enabled
+ atf_check cmp -s "$ROOT/etc/pkg/FreeBSD.conf" "$ROOT/base.before"
+ # Removing the local override exposes the base value again and
+ # takes the emptied object with it
+ atf_check "$SYSCONF" pkg -R "$ROOT" -x FreeBSD-ports.enabled
+ atf_check -o inline:'true\n' \
+ "$SYSCONF" pkg -R "$ROOT" -n FreeBSD-ports.enabled
+ atf_check -o match:'/etc/pkg/FreeBSD.conf$' \
+ "$SYSCONF" pkg -R "$ROOT" -n -F FreeBSD-ports.enabled
+ atf_check -o not-match:'FreeBSD-ports' \
+ cat "$ROOT/usr/local/etc/pkg/repos/FreeBSD.conf"
+}
+pkg_repos_override_cleanup()
+{
+ cleanup_root
+}
+
atf_init_test_cases()
{
atf_add_test_case make_src_knobs
@@ -701,4 +1183,10 @@
atf_add_test_case target_prefix
atf_add_test_case poudriere_sysrc
atf_add_test_case poudriere_overlays
+ atf_add_test_case ucl_groups
+ atf_add_test_case ucl_hostile_input
+ atf_add_test_case ucl_file_shapes
+ atf_add_test_case ucl_replace
+ atf_add_test_case ucl_replace_owner
+ atf_add_test_case pkg_repos_override
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Wed, Nov 4, 4:56 PM (8 h, 32 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
41363464
Default Alt Text
D61064.1793811383.diff (148 KB)
Attached To
Mode
D61064: sysconf: add ucl and pkg targets
Attached
Detach File
Event Timeline