Page MenuHomeFreeBSD

D61064.1793810478.diff
No OneTemporary

Authored By
Unknown
Size
148 KB
Referenced Files
None
Subscribers
None

D61064.1793810478.diff

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

Mime Type
text/plain
Expires
Wed, Nov 4, 4:41 PM (8 h, 17 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
41363464
Default Alt Text
D61064.1793810478.diff (148 KB)

Event Timeline