Merge pull request #25180 from keszybz/ukify

ukify: add helper to create UKIs
This commit is contained in:
Lennart Poettering 2022-12-08 15:11:18 +01:00 committed by GitHub
commit a579990277
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1844 additions and 54 deletions

View file

@ -53,6 +53,7 @@ PACKAGES=(
python3-evdev
python3-jinja2
python3-lxml
python3-pefile
python3-pip
python3-pyparsing
python3-setuptools

View file

@ -21,6 +21,7 @@ ADDITIONAL_DEPS=(
libzstd-dev
perl
python3-libevdev
python3-pefile
python3-pyparsing
rpm
zstd

1
README
View file

@ -207,6 +207,7 @@ REQUIREMENTS:
docbook-xsl (optional, required for documentation)
xsltproc (optional, required for documentation)
python-jinja2
python-pefile
python-lxml (optional, required to build the indices)
python >= 3.5
meson >= 0.53.2

View file

@ -1191,6 +1191,7 @@ manpages = [
''],
['udev_new', '3', ['udev_ref', 'udev_unref'], ''],
['udevadm', '8', [], ''],
['ukify', '1', [], 'HAVE_GNU_EFI'],
['user@.service',
'5',
['systemd-user-runtime-dir', 'user-runtime-dir@.service'],

312
man/ukify.xml Normal file
View file

@ -0,0 +1,312 @@
<?xml version="1.0"?>
<!--*-nxml-*-->
<!DOCTYPE refentry PUBLIC "-//OASIS//DTD DocBook XML V4.5//EN"
"http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd">
<!-- SPDX-License-Identifier: LGPL-2.1-or-later -->
<refentry id="ukify" xmlns:xi="http://www.w3.org/2001/XInclude" conditional='HAVE_GNU_EFI'>
<refentryinfo>
<title>ukify</title>
<productname>systemd</productname>
</refentryinfo>
<refmeta>
<refentrytitle>ukify</refentrytitle>
<manvolnum>1</manvolnum>
</refmeta>
<refnamediv>
<refname>ukify</refname>
<refpurpose>Combine kernel and initrd into a signed Unified Kernel Image</refpurpose>
</refnamediv>
<refsynopsisdiv>
<cmdsynopsis>
<command>/usr/lib/systemd/ukify</command>
<arg choice="plain"><replaceable>LINUX</replaceable></arg>
<arg choice="plain" rep="repeat"><replaceable>INITRD</replaceable></arg>
<arg choice="opt" rep="repeat">OPTIONS</arg>
</cmdsynopsis>
</refsynopsisdiv>
<refsect1>
<title>Description</title>
<para>Note: this command is experimental for now. While it is intended to become a regular component of
systemd, it might still change in behaviour and interface.</para>
<para><command>ukify</command> is a tool that combines a kernel and an initrd with
a UEFI boot stub to create a
<ulink url="https://uapi-group.org/specifications/specs/unified_kernel_image/">Unified Kernel Image (UKI)</ulink>
— a PE binary that can be executed by the firmware to start the embedded linux kernel.
See <citerefentry><refentrytitle>systemd-stub</refentrytitle><manvolnum>7</manvolnum></citerefentry>
for details about the stub.</para>
<para>Additional sections will be inserted into the UKI, either automatically or only if a specific
option is provided. See the discussions of
<option>--cmdline=</option>,
<option>--os-release=</option>,
<option>--devicetree=</option>,
<option>--splash=</option>,
<option>--pcrpkey=</option>,
<option>--uname=</option>,
and <option>--section=</option>
below.</para>
<para>If PCR signing keys are provided via the <option>--pcr-public-key=</option> and
<option>--pcr-private-key=</option> options, PCR values that will be seen after booting with the given
kernel, initrd, and other sections, will be calculated, signed, and embedded in the UKI.
<citerefentry><refentrytitle>systemd-measure</refentrytitle><manvolnum>1</manvolnum></citerefentry> is
used to perform this calculation and signing.</para>
<para>The calculation of PCR values is done for specific boot phase paths. Those can be specified with
<option>--phases=</option> option. If not specified, the default provided by
<command>systemd-measure</command> is used. It is also possible to specify the
<option>--pcr-private-key=</option>, <option>--pcr-public-key=</option>, and <option>--phases=</option>
arguments more than once. Signatures will be then performed with each of the specified keys. When both
<option>--phases=</option> and <option>--pcr-private-key=</option> are used, they must be specified the
same number of times, and then the n-th boot phase path set will be signed by the n-th key. This can be
used to build different trust policies for different phases of the boot.</para>
<para>If a SecureBoot signing key is provided via the <option>--secureboot-private-key=</option> option,
the resulting PE binary will be signed as a whole, allowing the resulting UKI to be trusted by
SecureBoot. Also see the discussion of automatic enrollment in
<citerefentry><refentrytitle>systemd-boot</refentrytitle><manvolnum>7</manvolnum></citerefentry>.
</para>
</refsect1>
<refsect1>
<title>Options</title>
<para>Note that the <replaceable>LINUX</replaceable> positional argument is mandatory. The
<replaceable>INITRD</replaceable> positional arguments are optional. If more than one is specified, they
will all be combined into a single PE section. This is useful to for example prepend microcode before the
actual initrd.</para>
<para>The following options are understood:</para>
<variablelist>
<varlistentry>
<term><option>--cmdline=<replaceable>TEXT</replaceable>|<replaceable>@PATH</replaceable></option></term>
<listitem><para>Specify the kernel command line (the <literal>.cmdline</literal> section). The
argument may be a literal string, or <literal>@</literal> followed by a path name. If not specified,
no command line will be embedded.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--os-release=<replaceable>TEXT</replaceable>|<replaceable>@PATH</replaceable></option></term>
<listitem><para>Specify the os-release description (the <literal>.osrel</literal> section). The
argument may be a literal string, or <literal>@</literal> followed by a path name. If not specified,
the <citerefentry><refentrytitle>os-release</refentrytitle><manvolnum>5</manvolnum></citerefentry>
file will be picked up from the host system.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--devicetree=<replaceable>PATH</replaceable></option></term>
<listitem><para>Specify the devicetree description (the <literal>.dtb</literal> section). The
argument is a path to a compiled binary DeviceTree file. If not specified, the section will not be
present.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--splash=<replaceable>PATH</replaceable></option></term>
<listitem><para>Specify a picture to display during boot (the <literal>.splash</literal> section).
The argument is a path to a BMP file. If not specified, the section will not be present.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--pcrpkey=<replaceable>PATH</replaceable></option></term>
<listitem><para>Specify a path to a public key to embed in the <literal>.pcrpkey</literal> section.
If not specified, and there's exactly one <option>--pcr-public-key=</option> argument, that key will
be used. Otherwise, the section will not be present.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--uname=<replaceable>VERSION</replaceable></option></term>
<listitem><para>Specify the kernel version (as in <command>uname -r</command>, the
<literal>.uname</literal> section). If not specified, an attempt will be made to extract the version
string from the kernel image. It is recommended to pass this explicitly if known, because the
extraction is based on heuristics and not very reliable. If not specified and extraction fails, the
section will not be present.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--section=<replaceable>NAME</replaceable>:<replaceable>TEXT</replaceable>|<replaceable>@PATH</replaceable></option></term>
<listitem><para>Specify an arbitrary additional section
<literal><replaceable>NAME</replaceable></literal>. Note that the name is used as-is, and if the
section name should start with a dot, it must be included in <replaceable>NAME</replaceable>. The
argument may be a literal string, or <literal>@</literal> followed by a path name. This option may be
specified more than once. Any sections specified in this fashion will be inserted (in order) before
the <literal>.linux</literal> section which is always last.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--pcr-private-key=<replaceable>PATH</replaceable></option></term>
<listitem><para>Specify a private key to use for signing PCR policies. This option may be specified
more than once, in which case multiple signatures will be made.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--pcr-public-key=<replaceable>PATH</replaceable></option></term>
<listitem><para>Specify a public key to use for signing PCR policies. This option may be specified
more than once, similarly to the <option>--pcr-private-key=</option> option. If not present, the
public keys will be extracted from the private keys. If present, the this option must be specified
the same number of times as the <option>--pcr-private-key=</option> option.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--phases=<replaceable>LIST</replaceable></option></term>
<listitem><para>A comma or space-separated list of colon-separated phase paths to sign a policy for.
If not present, the default of
<citerefentry><refentrytitle>systemd-measure</refentrytitle><manvolnum>1</manvolnum></citerefentry>
will be used. When this argument is present, it must appear the same number of times as the
<option>--pcr-private-key=</option> option. Each set of boot phase paths will be signed with the
corresponding private key.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--pcr-banks=<replaceable>PATH</replaceable></option></term>
<listitem><para>A comma or space-separated list of PCR banks to sign a policy for. If not present,
all known banks will be used (<literal>sha1</literal>, <literal>sha256</literal>,
<literal>sha384</literal>, <literal>sha512</literal>), which will fail if not supported by the
system.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--secureboot-private-key=<replaceable>SB_KEY</replaceable></option></term>
<listitem><para>A path to a private key to use for signing of the resulting binary. If the
<option>--signing-engine=</option> option is used, this may also be an engine-specific
designation.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--secureboot-certificate=<replaceable>SB_CERT</replaceable></option></term>
<listitem><para>A path to a certificate to use for signing of the resulting binary. If the
<option>--signing-engine=</option> option is used, this may also be an engine-specific
designation.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--signing-engine=<replaceable>ENGINE</replaceable></option></term>
<listitem><para>An "engine" to for signing of the resulting binary. This option is currently passed
verbatim to the <option>--engine=</option> option of
<citerefentry><refentrytitle>sbsign</refentrytitle><manvolnum>1</manvolnum></citerefentry>.
</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--sign-kernel</option></term>
<term><option>--no-sign-kernel</option></term>
<listitem><para>Override the detection of whether to sign the Linux binary itself before it is
embedded in the combined image. If not specified, it will be signed if a SecureBoot signing key is
provided via the <option>--secureboot-private-key=</option> option and the binary has not already
been signed. If <option>--sign-kernel</option> is specified, and the binary has already been signed,
the signature will be appended anyway.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--tools=<replaceable>DIR</replaceable></option></term>
<listitem><para>Specify a directory with helper tools. <command>ukify</command> will look for helper
tools in that directory first, and if not found, try to load them from <varname>$PATH</varname> in
the usual fashion.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--measure</option></term>
<term><option>--no-measure</option></term>
<listitem><para>Enable or disable a call to <command>systmed-measure</command> to print
pre-calculated PCR values. Defaults to false.</para></listitem>
</varlistentry>
<varlistentry>
<term><option>--output=<replaceable>FILENAME</replaceable></option></term>
<listitem><para>The output filename. If not specified, the name of the
<replaceable>LINUX</replaceable> argument, with the suffix <literal>.unsigned.efi</literal> or
<literal>.signed.efi</literal> will be used, depending on whether signing for SecureBoot was
performed.</para></listitem>
</varlistentry>
<xi:include href="standard-options.xml" xpointer="help" />
<xi:include href="standard-options.xml" xpointer="version" />
</variablelist>
</refsect1>
<refsect1>
<title>Examples</title>
<example>
<title>Minimal invocation</title>
<programlisting>ukify \
/lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \
/some/path/initramfs-6.0.9-300.fc37.x86_64.img \
--cmdline='quiet rw'
</programlisting>
<para>This creates an unsigned UKI <filename>./vmlinuz.unsigned.efi</filename>.</para>
</example>
<example>
<title>All the bells and whistles</title>
<programlisting>/usr/lib/systemd/ukify \
/lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \
early_cpio \
/some/path/initramfs-6.0.9-300.fc37.x86_64.img \
--pcr-private-key=pcr-private-initrd-key.pem \
--pcr-public-key=pcr-public-initrd-key.pem \
--phases='enter-initrd' \
--pcr-private-key=pcr-private-system-key.pem \
--pcr-public-key=pcr-public-system-key.pem \
--phases='enter-initrd:leave-initrd enter-initrd:leave-initrd:sysinit \
enter-initrd:leave-initrd:sysinit:ready' \
--pcr-banks=sha384,sha512 \
--secureboot-private-key=sb.key \
--secureboot-certificate=sb.cert \
--sign-kernel \
--cmdline='quiet rw rhgb'
</programlisting>
<para>This creates a signed UKI <filename index='false'>./vmlinuz.signed.efi</filename>.
The initrd section contains two concatenated parts, <filename index='false'>early_cpio</filename>
and <filename index='false'>initramfs-6.0.9-300.fc37.x86_64.img</filename>.
The policy embedded in the <literal>.pcrsig</literal> section will be signed for the initrd (the
<constant>enter-initrd</constant> phase) with the key
<filename index='false'>pcr-private-initrd-key.pem</filename>, and for the main system (phases
<constant>leave-initrd</constant>, <constant>sysinit</constant>, <constant>ready</constant>) with the
key <filename index='false'>pcr-private-system-key.pem</filename>. The Linux binary and the resulting
combined image will be signed with the SecureBoot key <filename index='false'>sb.key</filename>.</para>
</example>
</refsect1>
<refsect1>
<title>See Also</title>
<para>
<citerefentry><refentrytitle>systemd</refentrytitle><manvolnum>1</manvolnum></citerefentry>,
<citerefentry><refentrytitle>systemd-stub</refentrytitle><manvolnum>7</manvolnum></citerefentry>,
<citerefentry><refentrytitle>systemd-boot</refentrytitle><manvolnum>7</manvolnum></citerefentry>,
<citerefentry project='man-pages'><refentrytitle>objcopy</refentrytitle><manvolnum>1</manvolnum></citerefentry>,
<citerefentry><refentrytitle>systemd-pcrphase.service</refentrytitle><manvolnum>1</manvolnum></citerefentry>
</para>
</refsect1>
</refentry>

View file

@ -716,6 +716,17 @@ if run_command(python, '-c', 'import jinja2', check : false).returncode() != 0
error('python3 jinja2 missing')
endif
python_310 = run_command(python, '-c',
'import sys; sys.exit(0 if sys.version_info >= (3,10) else 1)',
check : false).returncode() == 0
if get_option('ukify') == 'auto'
want_ukify = python_310
elif get_option('ukify') == 'true' and not python310
error('ukify requires Python >= 3.10')
else
want_ukify = get_option('ukify') == 'true'
endif
############################################################
gperf = find_program('gperf')
@ -2191,6 +2202,7 @@ subdir('src/test')
subdir('src/fuzz')
subdir('rules.d')
subdir('test')
subdir('src/ukify/test') # needs to be last for test_env variable
############################################################
@ -2587,7 +2599,7 @@ if conf.get('HAVE_BLKID') == 1 and conf.get('HAVE_GNU_EFI') == 1
boot_link_with = [libsystemd_static, libshared_static]
endif
public_programs += executable(
exe = executable(
'bootctl',
'src/boot/bootctl.c',
include_directories : includes,
@ -2596,6 +2608,14 @@ if conf.get('HAVE_BLKID') == 1 and conf.get('HAVE_GNU_EFI') == 1
versiondep],
install_rpath : rootpkglibdir,
install : true)
public_programs += exe
if want_tests != 'false'
test('test-bootctl-json',
test_bootctl_json_sh,
args : exe.full_path(),
depends : exe)
endif
public_programs += executable(
'systemd-bless-boot',
@ -4008,6 +4028,18 @@ if want_tests != 'false' and want_kernel_install
args : [exe.full_path(), loaderentry_install])
endif
if want_ukify
exe = custom_target(
'ukify',
input : 'src/ukify/ukify.py',
output : 'ukify',
command : [jinja2_cmdline, '@INPUT@', '@OUTPUT@'],
install : true,
install_mode : 'rwxr-xr-x',
install_dir : rootlibexecdir)
public_programs += exe
endif
############################################################
runtest_env = custom_target(

View file

@ -501,6 +501,8 @@ option('llvm-fuzz', type : 'boolean', value : false,
description : 'build against LLVM libFuzzer')
option('kernel-install', type: 'boolean', value: true,
description : 'install kernel-install and associated files')
option('ukify', type : 'combo', choices : ['auto', 'true', 'false'],
description : 'install ukify')
option('analyze', type: 'boolean', value: true,
description : 'install systemd-analyze')

View file

@ -1404,6 +1404,8 @@ int show_boot_entries(const BootConfig *config, JsonFormatFlags json_format) {
assert(config);
if (!FLAGS_SET(json_format, JSON_FORMAT_OFF)) {
_cleanup_(json_variant_unrefp) JsonVariant *array = NULL;
for (size_t i = 0; i < config->n_entries; i++) {
_cleanup_free_ char *opts = NULL;
const BootEntry *e = config->entries + i;
@ -1443,9 +1445,13 @@ int show_boot_entries(const BootConfig *config, JsonFormatFlags json_format) {
if (r < 0)
return log_oom();
json_variant_dump(v, json_format, stdout, NULL);
r = json_variant_append_array(&array, v);
if (r < 0)
return log_oom();
}
json_variant_dump(array, json_format | JSON_FORMAT_EMPTY_ARRAY, NULL, NULL);
} else {
for (size_t n = 0; n < config->n_entries; n++) {
r = show_boot_entry(

View file

@ -553,9 +553,34 @@ static void json_variant_copy_source(JsonVariant *v, JsonVariant *from) {
v->source = json_source_ref(from->source);
}
static int _json_variant_array_put_element(JsonVariant *array, JsonVariant *element) {
assert(array);
JsonVariant *w = array + 1 + array->n_elements;
uint16_t d = json_variant_depth(element);
if (d >= DEPTH_MAX) /* Refuse too deep nesting */
return -ELNRNG;
if (d >= array->depth)
array->depth = d + 1;
array->n_elements ++;
*w = (JsonVariant) {
.is_embedded = true,
.parent = array,
};
json_variant_set(w, element);
json_variant_copy_source(w, element);
if (!json_variant_is_normalized(element))
array->normalized = false;
return 0;
}
int json_variant_new_array(JsonVariant **ret, JsonVariant **array, size_t n) {
_cleanup_(json_variant_unrefp) JsonVariant *v = NULL;
bool normalized = true;
int r;
assert_return(ret, -EINVAL);
if (n == 0) {
@ -571,33 +596,15 @@ int json_variant_new_array(JsonVariant **ret, JsonVariant **array, size_t n) {
*v = (JsonVariant) {
.n_ref = 1,
.type = JSON_VARIANT_ARRAY,
.normalized = true,
};
for (v->n_elements = 0; v->n_elements < n; v->n_elements++) {
JsonVariant *w = v + 1 + v->n_elements,
*c = array[v->n_elements];
uint16_t d;
d = json_variant_depth(c);
if (d >= DEPTH_MAX) /* Refuse too deep nesting */
return -ELNRNG;
if (d >= v->depth)
v->depth = d + 1;
*w = (JsonVariant) {
.is_embedded = true,
.parent = v,
};
json_variant_set(w, c);
json_variant_copy_source(w, c);
if (!json_variant_is_normalized(c))
normalized = false;
while (v->n_elements < n) {
r = _json_variant_array_put_element(v, array[v->n_elements]);
if (r < 0)
return r;
}
v->normalized = normalized;
*ret = TAKE_PTR(v);
return 0;
}
@ -823,6 +830,19 @@ static void json_variant_free_inner(JsonVariant *v, bool force_sensitive) {
explicit_bzero_safe(v, json_variant_size(v));
}
static unsigned json_variant_n_ref(const JsonVariant *v) {
/* Return the number of references to v.
* 0 => NULL or not a regular object or embedded.
* >0 => number of references
*/
if (!v || !json_variant_is_regular(v) || v->is_embedded)
return 0;
assert(v->n_ref > 0);
return v->n_ref;
}
JsonVariant *json_variant_ref(JsonVariant *v) {
if (!v)
return NULL;
@ -1790,8 +1810,12 @@ int json_variant_format(JsonVariant *v, JsonFormatFlags flags, char **ret) {
}
int json_variant_dump(JsonVariant *v, JsonFormatFlags flags, FILE *f, const char *prefix) {
if (!v)
return 0;
if (!v) {
if (flags & JSON_FORMAT_EMPTY_ARRAY)
v = JSON_VARIANT_MAGIC_EMPTY_ARRAY;
else
return 0;
}
if (!f)
f = stdout;
@ -2072,28 +2096,54 @@ int json_variant_append_array(JsonVariant **v, JsonVariant *element) {
if (!*v || json_variant_is_null(*v))
blank = true;
else if (!json_variant_is_array(*v))
return -EINVAL;
else
else if (json_variant_is_array(*v))
blank = json_variant_elements(*v) == 0;
else
return -EINVAL;
if (blank)
if (blank) {
r = json_variant_new_array(&nv, (JsonVariant*[]) { element }, 1);
else {
_cleanup_free_ JsonVariant **array = new(JsonVariant*, json_variant_elements(*v) + 1);
if (r < 0)
return r;
} else if (json_variant_n_ref(*v) == 1) {
/* Let's bump the reference count on element. We can't do the realloc if we're appending *v
* to itself, or one of the objects embedded in *v to *v. If the reference count grows, we
* need to fall back to the other method below. */
_unused_ _cleanup_(json_variant_unrefp) JsonVariant *dummy = json_variant_ref(element);
if (json_variant_n_ref(*v) == 1) {
/* We hold the only reference. Let's mutate the object. */
size_t size = json_variant_elements(*v);
void *old = *v;
if (!GREEDY_REALLOC(*v, size + 1 + 1))
return -ENOMEM;
if (old != *v)
/* Readjust the parent pointers to the new address */
for (size_t i = 1; i < size; i++)
(*v)[1 + i].parent = *v;
return _json_variant_array_put_element(*v, element);
}
}
if (!blank) {
size_t size = json_variant_elements(*v);
_cleanup_free_ JsonVariant **array = new(JsonVariant*, size + 1);
if (!array)
return -ENOMEM;
size_t size = json_variant_elements(*v);
for (size_t i = 0; i < size; i++)
array[i] = json_variant_by_index(*v, i);
array[size] = element;
r = json_variant_new_array(&nv, array, size + 1);
if (r < 0)
return r;
}
if (r < 0)
return r;
json_variant_propagate_sensitive(*v, nv);
JSON_VARIANT_REPLACE(*v, TAKE_PTR(nv));
@ -3180,16 +3230,53 @@ finish:
return r;
}
int json_parse(const char *input, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column) {
return json_parse_internal(&input, NULL, flags, ret, ret_line, ret_column, false);
int json_parse_with_source(
const char *input,
const char *source,
JsonParseFlags flags,
JsonVariant **ret,
unsigned *ret_line,
unsigned *ret_column) {
_cleanup_(json_source_unrefp) JsonSource *s = NULL;
if (source) {
s = json_source_new(source);
if (!s)
return -ENOMEM;
}
return json_parse_internal(&input, s, flags, ret, ret_line, ret_column, false);
}
int json_parse_continue(const char **p, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column) {
return json_parse_internal(p, NULL, flags, ret, ret_line, ret_column, true);
int json_parse_with_source_continue(
const char **p,
const char *source,
JsonParseFlags flags,
JsonVariant **ret,
unsigned *ret_line,
unsigned *ret_column) {
_cleanup_(json_source_unrefp) JsonSource *s = NULL;
if (source) {
s = json_source_new(source);
if (!s)
return -ENOMEM;
}
return json_parse_internal(p, s, flags, ret, ret_line, ret_column, true);
}
int json_parse_file_at(FILE *f, int dir_fd, const char *path, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column) {
_cleanup_(json_source_unrefp) JsonSource *source = NULL;
int json_parse_file_at(
FILE *f,
int dir_fd,
const char *path,
JsonParseFlags flags,
JsonVariant **ret,
unsigned *ret_line,
unsigned *ret_column) {
_cleanup_free_ char *text = NULL;
int r;
@ -3205,14 +3292,7 @@ int json_parse_file_at(FILE *f, int dir_fd, const char *path, JsonParseFlags fla
if (isempty(text))
return -ENODATA;
if (path) {
source = json_source_new(path);
if (!source)
return -ENOMEM;
}
const char *p = text;
return json_parse_internal(&p, source, flags, ret, ret_line, ret_column, false);
return json_parse_with_source(text, path, flags, ret, ret_line, ret_column);
}
int json_buildv(JsonVariant **ret, va_list ap) {

View file

@ -194,7 +194,8 @@ typedef enum JsonFormatFlags {
JSON_FORMAT_SSE = 1 << 6, /* prefix/suffix with W3C server-sent events */
JSON_FORMAT_SEQ = 1 << 7, /* prefix/suffix with RFC 7464 application/json-seq */
JSON_FORMAT_FLUSH = 1 << 8, /* call fflush() after dumping JSON */
JSON_FORMAT_OFF = 1 << 9, /* make json_variant_format() fail with -ENOEXEC */
JSON_FORMAT_EMPTY_ARRAY = 1 << 9, /* output "[]" for empty input */
JSON_FORMAT_OFF = 1 << 10, /* make json_variant_format() fail with -ENOEXEC */
} JsonFormatFlags;
int json_variant_format(JsonVariant *v, JsonFormatFlags flags, char **ret);
@ -222,8 +223,16 @@ typedef enum JsonParseFlags {
JSON_PARSE_SENSITIVE = 1 << 0, /* mark variant as "sensitive", i.e. something containing secret key material or such */
} JsonParseFlags;
int json_parse(const char *string, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column);
int json_parse_continue(const char **p, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column);
int json_parse_with_source(const char *string, const char *source, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column);
int json_parse_with_source_continue(const char **p, const char *source, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column);
static inline int json_parse(const char *string, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column) {
return json_parse_with_source(string, NULL, flags, ret, ret_line, ret_column);
}
static inline int json_parse_continue(const char **p, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column) {
return json_parse_with_source_continue(p, NULL, flags, ret, ret_line, ret_column);
}
int json_parse_file_at(FILE *f, int dir_fd, const char *path, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column);
static inline int json_parse_file(FILE *f, const char *path, JsonParseFlags flags, JsonVariant **ret, unsigned *ret_line, unsigned *ret_column) {

View file

@ -663,4 +663,67 @@ TEST(json_append) {
assert_se(json_variant_equal(v, w));
}
static inline void json_array_append_with_source_one(bool source) {
_cleanup_(json_variant_unrefp) JsonVariant *a, *b;
/* Parse two sources, each with a different name and line/column numbers */
assert_se(json_parse_with_source(" [41]", source ? "string 1" : NULL, 0,
&a, NULL, NULL) >= 0);
assert_se(json_parse_with_source("\n\n [42]", source ? "string 2" : NULL, 0,
&b, NULL, NULL) >= 0);
assert_se(json_variant_is_array(a));
assert_se(json_variant_elements(a) == 1);
assert_se(json_variant_is_array(b));
assert_se(json_variant_elements(b) == 1);
/* Verify source information */
const char *s1, *s2;
unsigned line1, col1, line2, col2;
assert_se(json_variant_get_source(a, &s1, &line1, &col1) >= 0);
assert_se(json_variant_get_source(b, &s2, &line2, &col2) >= 0);
assert_se(streq_ptr(s1, source ? "string 1" : NULL));
assert_se(streq_ptr(s2, source ? "string 2" : NULL));
assert_se(line1 == 1);
assert_se(col1 == 2);
assert_se(line2 == 3);
assert_se(col2 == 4);
/* Append one elem from the second array (and source) to the first. */
JsonVariant *elem;
assert_se(elem = json_variant_by_index(b, 0));
assert_se(json_variant_is_integer(elem));
assert_se(json_variant_elements(elem) == 0);
assert_se(json_variant_append_array(&a, elem) >= 0);
assert_se(json_variant_is_array(a));
assert_se(json_variant_elements(a) == 2);
/* Verify that source information was propagated correctly */
assert_se(json_variant_get_source(elem, &s1, &line1, &col1) >= 0);
assert_se(elem = json_variant_by_index(a, 1));
assert_se(json_variant_get_source(elem, &s2, &line2, &col2) >= 0);
assert_se(streq_ptr(s1, source ? "string 2" : NULL));
assert_se(streq_ptr(s2, source ? "string 2" : NULL));
assert_se(line1 == 3);
assert_se(col1 == 5);
assert_se(line2 == 3);
assert_se(col2 == 5);
}
TEST(json_array_append_with_source) {
json_array_append_with_source_one(true);
}
TEST(json_array_append_without_source) {
json_array_append_with_source_one(false);
}
DEFINE_TEST_MAIN(LOG_DEBUG);

View file

@ -0,0 +1,23 @@
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURsVENDQW4yZ0F3SUJBZ0lVTzlqUWhhblhj
b3ViOERzdXlMMWdZbksrR1lvd0RRWUpLb1pJaHZjTkFRRUwKQlFBd1dURUxNQWtHQTFVRUJoTUNX
Rmd4RlRBVEJnTlZCQWNNREVSbFptRjFiSFFnUTJsMGVURWNNQm9HQTFVRQpDZ3dUUkdWbVlYVnNk
Q0JEYjIxd1lXNTVJRXgwWkRFVk1CTUdBMVVFQXd3TWEyVjVJSE5wWjI1cGJtbG5NQ0FYCkRUSXlN
VEF5T1RFM01qY3dNVm9ZRHpNd01qSXdNekF4TVRjeU56QXhXakJaTVFzd0NRWURWUVFHRXdKWVdE
RVYKTUJNR0ExVUVCd3dNUkdWbVlYVnNkQ0JEYVhSNU1Sd3dHZ1lEVlFRS0RCTkVaV1poZFd4MElF
TnZiWEJoYm5rZwpUSFJrTVJVd0V3WURWUVFEREF4clpYa2djMmxuYm1sdWFXY3dnZ0VpTUEwR0NT
cUdTSWIzRFFFQkFRVUFBNElCCkR3QXdnZ0VLQW9JQkFRREtVeHR4Y0d1aGYvdUp1SXRjWEhvdW0v
RE9RL1RJM3BzUWlaR0ZWRkJzbHBicU5wZDUKa2JDaUFMNmgrY1FYaGRjUmlOT1dBR0wyMFZ1T2Rv
VTZrYzlkdklGQnFzKzc2NHhvWGY1UGd2SlhvQUxSUGxDZAp4YVdPQzFsOFFIRHpxZ09SdnREMWNI
WFoveTkvZ1YxVU1GK1FlYm12aUhRN0U4eGw1T2h5MG1TQVZYRDhBTitsCjdpMUR6N0NuTzhrMVph
alhqYXlpNWV1WEV0TnFSZXNuVktRRElTQ0t2STFueUxySWxHRU1GZmFuUmRLQWthZ3MKalJnTmVh
T3N3aklHNjV6UzFVdjJTZXcxVFpIaFhtUmd5TzRVT0JySHZlSml2T2hObzU3UlRKd0M2K2lGY0FG
aApSSnorVmM2QUlSSkI1ZWtJUmdCN3VDNEI5ZmwydXdZKytMODNBZ01CQUFHalV6QlJNQjBHQTFV
ZERnUVdCQlFqCllIMnpzVFlPQU51MkcweXk1QkxlOHBvbWZUQWZCZ05WSFNNRUdEQVdnQlFqWUgy
enNUWU9BTnUyRzB5eTVCTGUKOHBvbWZUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BMEdDU3FHU0li
M0RRRUJDd1VBQTRJQkFRQ2dxcmFXaE51dQptUmZPUjVxcURVcC83RkpIL1N6Zk1vaDBHL2lWRkhv
OUpSS0tqMUZ2Q0VZc1NmeThYTmdaUDI5eS81Z0h4cmcrCjhwZWx6bWJLczdhUTRPK01TcmIzTm11
V1IzT0M0alBoNENrM09ZbDlhQy9iYlJqSWFvMDJ6K29XQWNZZS9xYTEKK2ZsemZWVEUwMHJ5V1RM
K0FJdDFEZEVqaG01WXNtYlgvbWtacUV1TjBtSVhhRXhSVE9walczUWRNeVRQaURTdApvanQvQWMv
R2RUWDd0QkhPTk44Z3djaC91V293aVNORERMUm1wM2VScnlOZ3RPKzBISUd5Qm16ZWNsM0VlVEo2
CnJzOGRWUFhqR1Z4dlZDb2tqQllrOWdxbkNGZEJCMGx4VXVNZldWdVkyRUgwSjI3aGh4SXNFc3ls
VTNIR1EyK2MKN1JicVY4VTNSRzA4Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K

View file

@ -0,0 +1,30 @@
LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2Z0lCQURBTkJna3Foa2lHOXcwQkFRRUZB
QVNDQktnd2dnU2tBZ0VBQW9JQkFRREtVeHR4Y0d1aGYvdUoKdUl0Y1hIb3VtL0RPUS9USTNwc1Fp
WkdGVkZCc2xwYnFOcGQ1a2JDaUFMNmgrY1FYaGRjUmlOT1dBR0wyMFZ1Twpkb1U2a2M5ZHZJRkJx
cys3NjR4b1hmNVBndkpYb0FMUlBsQ2R4YVdPQzFsOFFIRHpxZ09SdnREMWNIWFoveTkvCmdWMVVN
RitRZWJtdmlIUTdFOHhsNU9oeTBtU0FWWEQ4QU4rbDdpMUR6N0NuTzhrMVphalhqYXlpNWV1WEV0
TnEKUmVzblZLUURJU0NLdkkxbnlMcklsR0VNRmZhblJkS0FrYWdzalJnTmVhT3N3aklHNjV6UzFV
djJTZXcxVFpIaApYbVJneU80VU9Cckh2ZUppdk9oTm81N1JUSndDNitpRmNBRmhSSnorVmM2QUlS
SkI1ZWtJUmdCN3VDNEI5ZmwyCnV3WSsrTDgzQWdNQkFBRUNnZ0VBQkhZQ28rU3JxdHJzaStQU3hz
MlBNQm5tSEZZcFBvaVIrTEpmMEFYRTVEQUoKMGM0MFZzemNqU1hoRGljNHFLQWQxdGdpZWlzMkEy
VW9WS0xPV3pVOTBqNUd4MURoMWEzaTRhWTQ1ajNuNUFDMgpMekRsakNVQWVucExsYzdCN3MxdjJM
WFJXNmdJSVM5Y043NTlkVTYvdktyQ2FsbGkzcTZZRWlNUzhQMHNsQnZFCkZtdEc1elFsOVJjV0gr
cHBqdzlIMTJSZ3BldUVJVEQ2cE0vd2xwcXZHRlUwcmZjM0NjMHhzaWdNTnh1Z1FJNGgKbnpjWDVs
OEs0SHdvbmhOTG9TYkh6OU5BK3p3QkpuUlZVSWFaaEVjSThtaEVPWHRaRkpYc01aRnhjS2l3SHFS
dApqUUVHOHJRa3lPLytXMmR5Z2czV1lNYXE1OWpUWVdIOUsrQmFyeEMzRVFLQmdRRFBNSFMycjgz
ZUpRTTlreXpkCndDdnlmWGhQVlVtbVJnOGwyWng0aC9tci9mNUdDeW5SdzRzT2JuZGVQd29tZ1Iz
cFBleFFGWlFFSExoZ1RGY3UKVk5uYXcrTzBFL1VnL01pRGswZDNXU0hVZXZPZnM1cEM2b3hYNjNT
VENwNkVLT2VEZlpVMW9OeHRsZ0YyRVhjcgpmVlZpSzFKRGk3N2dtaENLcFNGcjBLK3gyUUtCZ1FE
NS9VUC9hNU52clExdUhnKzR0SzJZSFhSK1lUOFREZG00Ck8xZmh5TU5lOHRYSkd5UUJjTktVTWg2
M2VyR1MwWlRWdGdkNHlGS3RuOGtLU2U4TmlacUl1aitVUVIyZ3pEQVAKQ2VXcXl2Y2pRNmovU1Yw
WjVvKzlTNytiOStpWWx5RTg2bGZobHh5Z21aNnptYisxUUNteUtNVUdBNis5VmUvMgo1MHhDMXBB
L2p3S0JnUUNEOHA4UnpVcDFZK3I1WnVaVzN0RGVJSXZqTWpTeVFNSGE0QWhuTm1tSjREcjBUcDIy
CmFpci82TmY2WEhsUlpqOHZVSEZUMnpvbG1FalBneTZ1WWZsUCtocmtqeVU0ZWVRVTcxRy9Mek45
UjBRcCs4Nk4KT1NSaHhhQzdHRE0xaFh0VFlVSUtJa1RmUVgzeXZGTEJqcE0yN3RINEZHSmVWWitk
UEdiWmE5REltUUtCZ1FENQpHTU5qeExiQnhhY25QYThldG5KdnE1SUR5RFRJY0xtc1dQMkZ6cjNX
WTVSZzhybGE4aWZ5WVVxNE92cXNPRWZjCjk2ZlVVNUFHejd2TWs4VXZNUmtaK3JRVnJ4aXR2Q2g3
STdxRkIvOWdWVEFWU080TE8vR29oczBqeGRBd0ZBK2IKbWtyOVQ4ekh2cXNqZlNWSW51bXRTL0Nl
d0plaHl2cjBoSjg1em9Fbnd3S0JnR1h6UXVDSjJDb3NHVVhEdnlHKwpyRzVBd3pUZGd0bHg4YTBK
NTg1OWtZbVd0cW5WYUJmbFdrRmNUcHNEaGZ2ZWVDUkswc29VRlNPWkcranpsbWJrCkpRL09aVkZJ
dG9MSVZCeE9qeWVXNlhUSkJXUzFRSkVHckkwY0tTbXNKcENtUXVPdUxMVnZYczV0U21CVmc5RXQK
MjZzUkZwcjVWWmsrZlNRa3RhbkM4NGV1Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K

View file

@ -0,0 +1,30 @@
LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2Z0lCQURBTkJna3Foa2lHOXcwQkFRRUZB
QVNDQktnd2dnU2tBZ0VBQW9JQkFRQzVuOHFhbzVNZ1BJUVcKc0F5Y2R3dnB1bjdNNHlRSW9FL3I3
ekFGTG1hZlBXclo3d2JaaUIyTkY1MVdHOEo4bnlDQkI3M0RLcmZaeWs5cwphQXdXVW5RR2t0dGFv
RXpXRzZSRTM3dXdQOUpVM09YdklTNTBhcy9KSHVHNlJPYmE2V0NOOFp2TTdkZGpvTDFKCkZlYnBS
SXI1Vi82VStMTFhrUnRNYVczUnZ6T0xYeU1NT2QzOEcxZ0d0VlRHcm90ejVldFgrTUNVU2lOVGFE
OVUKN1dEZXVsZXVpMlRnK1I3TGRoSXg3ZTQ5cEhRM3d6a1NxeFQ4SGpoU3ZURWpITWVSNjIwaUhF
ZW9uYzdsMXVnagpzY1pwTktHdk13bXUvU2ptWFp6UkpOdjVOU0txcEVnQll2RnFkS3dUdlc4MWl6
SUFvN3paMkx6NDJYb25zSWJ2CjNrbGZqTG1mQWdNQkFBRUNnZ0VBQXozYm8yeTAzb3kvLzhkdVNQ
TTVSWWtvdXJwQ3dGWFFYMzNyV0VQUnJmazgKR3ZjMkp1bGVIcjhwVTc0alhOcklqZ2hORTVIMDZQ
eEQrOUFyV2Q1eHdVV2lTQWhobnlHWGNrNTM4Q0dGTWs4egpRc1JSRTk1anA0Ny9BU28vMzlYUWhs
b1FUdmxlV0JLUUM2MHl2YU1oVEM1eHR6ZEtwRUlYK0hNazVGTlMrcDJVCmxtL3AzVE1YWDl1bmc5
Mk9pTzUzV1VreFpQN2cwTVJHbGJrNzhqc1dkdjFYY0tLRjhuVmU5WC9NR1lTYlVLNy8KM2NYazFR
WTRUdVZaQlBFSE12RFRpWWwxbmdDd1ZuL2MyY3JQU3hJRFdFWlhEdm90SFUwQkNQZURVckxGa0F5
cQpDaloza3MzdEh4am42STkraEVNcUJDMzY1MHFjdDNkZ0RVV2loc2MzdVFLQmdRRG1mVTNKc29K
QWFOdmxCbXgyClhzRDRqbXlXV1F2Z244cVNVNG03a2JKdmprcUJ6VnB0T0ZsYmk2ejZHOXR6ZHNX
a0dJSjh3T0ZRb1hlM0dKOFIKSlVpeEFXTWZOM1JURGo5VjVXbzZJdE5EbzM1N3dNbVVYOW1qeThF
YXp0RE1BckdSNGJva0Q5RjY3clhqSGdSMQpaZVcvSDlUWHFUV1l4VHl6UDB3ZDBQeUZ4d0tCZ1FE
T0swWHVQS0o0WG00WmFCemN0OTdETXdDcFBSVmVvUWU3CmkzQjRJQ3orWFZ4cVM2amFTY2xNeEVm
Nk5tM2tLNERDR1dwVkpXcm9qNjlMck1KWnQzTlI2VUJ5NzNqUVBSamsKRXk5N3YrR04yVGwwNjFw
ZUxUM0dRS2RhT2VxWldpdElOcFc1dUxHL1poMGhoRUY5c1lSVTRtUFYwUWpla2kvdgp1bnVmcWx0
TmFRS0JnQTl6TE1pdFg0L0R0NkcxZVlYQnVqdXZDRlpYcDdVcDRPRklHajVwZU1XRGl6a0NNK0tJ
CldXMEtndERORnp1NUpXeG5mQyt5bWlmV2V2alovS2Vna1N2VVJQbXR0TzF3VWd5RzhVVHVXcXo1
QTV4MkFzMGcKVTYxb0ZneWUrbDRDZkRha0k5OFE5R0RDS1kwTTBRMnhnK0g0MTBLUmhCYzJlV2dt
Z1FxcW5KSzNBb0dCQU1rZgpnOWZXQlBVQndjdzlPYkxFR0tjNkVSSUlTZG1IbytCOE5kcXFJTnAv
djFEZXdEazZ0QXFVakZiMlZCdTdxSjh4ClpmN3NRcS9ldzdaQ01WS09XUXgyVEc0VFdUdGo3dTFJ
SGhGTjdiNlFRN0hnaXNiR3diV3VpdFBGSGl3OXYyMXgKK253MFJnb2VscHFFeDlMVG92R2Y3SjdB
ampONlR4TkJTNnBGNlUzSkFvR0JBT0tnbHlRWDJpVG5oMXd4RG1TVQo4RXhoQVN3S09iNS8yRmx4
aUhtUHVjNTZpS0tHY0lkV1cxMUdtbzdJakNzSTNvRm9iRkFjKzBXZkMvQTdMNWlmCjNBYVNWcmh0
cThRRklRaUtyYUQ0YlRtRk9Famg5QVVtUHMrWnd1OE9lSXJBSWtwZDV3YmlhTEJLd0pRbVdtSFAK
dUNBRTA3cXlSWXJ0c3QvcnVSSG5IdFA1Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K

View file

@ -0,0 +1,30 @@
LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZB
QVNDQktZd2dnU2lBZ0VBQW9JQkFRQzJ2Nk1oZHg3a3VjUHIKbmtFNFIrY3FnV2Y5T3B1c2h2M2o3
SG50K08wdi84d2l2T1BFNTlLMHYvRWJOOG94TDZEWUNXU0JCRU4vREJ5MgpMUTYwbldSdHBZN2Ju
bEcrcEtVeTRvSDRNZXZCR2JqZUhrak9LU3dNYVVWNGs4UmVSSjg4cVZ1U1MxSnVORW1NCmd5SERF
NGFPNG5ndG5UUFZZdzUydVBIcG1rN0E4VFdXN2lLZE5JWWZWOCtuR1pENXIzRWllekRsUUNORG54
UkcKdm5uSFZ6VFhZR3RwY2xaeWlJclpVekpBNFFPZnRueXB5UDVrQS94NVM1MU9QeGFxWlA3eGtP
S0NicUUvZmZvMApFTi9rTno0N0ZoUGUxbVBHUkZZWldHZXg0aWFPdHlLdHhnU1FYYkdlNEVoeVR4
SjJlT3U4QUVoVklTdjh6UU9nClNtbWx2UGQvQWdNQkFBRUNnZ0VBUUFpRERRRlR3bG96QTVhMmpK
VnBNdlFYNzF0L1c2TUxTRGMrZS90cWhKU1IKUHlUSGZHR3NhMmdMLy9qNjhHUWJiRWRTUDRDeWM4
eFhMU0E1bEdESDVVR0svbm9KYzQ3MlVZK2JjYzl3SjMrdgpUcWoyNHNIN2JMZmdQMEVybjhwVXIy
azZMRmNYSVlWUnRobm1sUmQ4NFFrS2loVVlxZTdsRFFWOXdsZ3V1eHpRCnBmVEtDTWk1bXJlYjIx
OExHS0QrMUxjVmVYZjExamc3Z2JnMllLZ1dOQ2R3VmIyUzJ5V0hTTjBlT3hPd21kWXIKSUVCekpG
eEc2MFJxSlJ1RzVIam9iemc2cy9ycUo1THFta3JhUWh6bHFPQVZLblpGOHppbG9vcDhXUXBQY3RN
cwp0cHBjczhtYkFkWHpoSTVjN0U1VVpDM2NJcEd6SE4raDZLK0F3R3ZEeVFLQmdRRDRBOTdQM29v
dGhoMHZHQmFWCnZWOXhHTm1YbW5TeUg0b29HcmJvaG1JWkkwVlhZdms5dWViSUJjbDZRMUx4WnN3
UFpRMVh5TUhpTjY1Z0E1emgKai9HZGcrdDlvcU5CZml0TUFrUTl1aWxvaXlKVWhYblk5REMvRitl
ZksycEpNbHdkci9qWEttRHpkQUZBVDgyWQpWRmJ3MlpLVi9GNEJNMUtCdDBZN0RPTmlad0tCZ1FD
OG9kZk0waytqL25VSzQ4TEV2MGtGbVNMdWdnTVlkM3hVCmZibmx0cUhFTVpJZU45OFVHK2hBWEdw
dU1Ya0JPM2Mwcm5ZRDVXZkNBRzFxT1V2ZTZzdHd6N0VuK3hWdlkvcWEKU3ZTaDRzMzhnZlBIeXhR
aGJvNWRwQTZUT3pwT0MyVi9rVXBVRUdJSmVVVllhQ05uWXNpUjRWUGVWL1lvR1htSwpQV29KbnAw
REtRS0JnQlk3cXBheDJXczVVWlp1TDJBZkNOWkhwd0hySzdqb0VPZUZkWTRrdGRpUkM5OUlsUlZP
CmUvekVZQXBnektldFVtK3kzRjVaTmVCRW81SWg0TWRyc3ZvdTRFWno5UFNqRGRpVGYzQ1ZKcThq
Z2VGWDBkTjgKR0g2WTh2K1cwY0ZjRFZ2djhYdkFaYzZOUUt0Mk8vVUM0b1JXek1nN1JtWVBKcjlR
SWJDYmVDclRBb0dBTjdZbApJbDFMSUVoYkVTaExzZ2c4N09aWnBzL0hVa2FYOWV4Y0p6aFZkcmlk
UzBkOUgxZE90Uk9XYTQwNUMrQWdTUEx0CjhDQ2xFR3RINVlPZW9Pdi93Z1hWY05WN2N6YTRJVEhh
SnFYeDZJNEpEZzB3bU44cU5RWHJPQmphRTRyU0kyY3AKNk1JZDhtWmEwTTJSQjB2cHFRdy8xUDl0
dUZJdHoySnNHd001cEdFQ2dZQVVnQVV3WENBcEtZVkZFRmxHNlBhYwpvdTBhdzdGNm1aMi9NNUcv
ek9tMHFDYnNXdGFNU09TdUEvNmlVOXB0NDBaWUFONFUvd2ZxbncyVkVoRnA3dzFNCnpZWmJCRDBx
ZVlkcDRmc1NuWXFMZmJBVmxQLzB6dmEzdkwwMlJFa25WalBVSnAvaGpKVWhBK21WN252VDZ5VjQK
cTg4SWVvOEx3Q1c1c2Jtd2lyU3Btdz09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K

View file

@ -0,0 +1,8 @@
LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FR
OEFNSUlCQ2dLQ0FRRUF1Wi9LbXFPVElEeUVGckFNbkhjTAo2YnArek9Na0NLQlA2Kzh3QlM1bW56
MXEyZThHMllnZGpSZWRWaHZDZko4Z2dRZTl3eXEzMmNwUGJHZ01GbEowCkJwTGJXcUJNMWh1a1JO
KzdzRC9TVk56bDd5RXVkR3JQeVI3aHVrVG0ydWxnamZHYnpPM1hZNkM5U1JYbTZVU0sKK1ZmK2xQ
aXkxNUViVEdsdDBiOHppMThqRERuZC9CdFlCclZVeHE2TGMrWHJWL2pBbEVvalUyZy9WTzFnM3Jw
WApyb3RrNFBrZXkzWVNNZTN1UGFSME44TTVFcXNVL0I0NFVyMHhJeHpIa2V0dEloeEhxSjNPNWRi
b0k3SEdhVFNoCnJ6TUpydjBvNWwyYzBTVGIrVFVpcXFSSUFXTHhhblNzRTcxdk5Zc3lBS084MmRp
OCtObDZKN0NHNzk1Slg0eTUKbndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==

View file

@ -0,0 +1,8 @@
LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FR
OEFNSUlCQ2dLQ0FRRUF0citqSVhjZTVMbkQ2NTVCT0VmbgpLb0ZuL1RxYnJJYjk0K3g1N2ZqdEwv
L01JcnpqeE9mU3RML3hHemZLTVMrZzJBbGtnUVJEZnd3Y3RpME90SjFrCmJhV08yNTVSdnFTbE11
S0IrREhyd1JtNDNoNUl6aWtzREdsRmVKUEVYa1NmUEtsYmtrdFNialJKaklNaHd4T0cKanVKNExa
MHoxV01PZHJqeDZacE93UEUxbHU0aW5UU0dIMWZQcHhtUSthOXhJbnN3NVVBalE1OFVScjU1eDFj
MAoxMkJyYVhKV2NvaUsyVk15UU9FRG43WjhxY2orWkFQOGVVdWRUajhXcW1UKzhaRGlnbTZoUDMz
Nk5CRGY1RGMrCk94WVQzdFpqeGtSV0dWaG5zZUltanJjaXJjWUVrRjJ4bnVCSWNrOFNkbmpydkFC
SVZTRXIvTTBEb0VwcHBiejMKZndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==

View file

@ -0,0 +1,7 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
if want_ukify and want_tests != 'false'
test('test-ukify',
files('test_ukify.py'),
env : test_env)
endif

2
src/ukify/test/setup.cfg Normal file
View file

@ -0,0 +1,2 @@
[tool:pytest]
addopts = --flakes

392
src/ukify/test/test_ukify.py Executable file
View file

@ -0,0 +1,392 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: LGPL-2.1+
# pylint: disable=missing-docstring,redefined-outer-name,invalid-name
# pylint: disable=unused-import,import-outside-toplevel,useless-else-on-loop
# pylint: disable=consider-using-with,wrong-import-position,unspecified-encoding
import base64
import json
import os
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
try:
import pytest
except ImportError:
sys.exit(77)
try:
# pyflakes: noqa
import pefile # noqa
except ImportError:
sys.exit(77)
# We import ukify.py, which is a template file. But only __version__ is
# substituted, which we don't care about here. Having the .py suffix makes it
# easier to import the file.
sys.path.append(os.path.dirname(__file__) + '/..')
import ukify
def test_guess_efi_arch():
arch = ukify.guess_efi_arch()
assert arch in ukify.EFI_ARCHES
def test_shell_join():
assert ukify.shell_join(['a', 'b', ' ']) == "a b ' '"
def test_round_up():
assert ukify.round_up(0) == 0
assert ukify.round_up(4095) == 4096
assert ukify.round_up(4096) == 4096
assert ukify.round_up(4097) == 8192
def test_parse_args_minimal():
opts = ukify.parse_args('arg1 arg2'.split())
assert opts.linux == pathlib.Path('arg1')
assert opts.initrd == [pathlib.Path('arg2')]
assert opts.os_release in (pathlib.Path('/etc/os-release'),
pathlib.Path('/usr/lib/os-release'))
def test_parse_args_many():
opts = ukify.parse_args(
['/ARG1', '///ARG2', '/ARG3 WITH SPACE',
'--cmdline=a b c',
'--os-release=K1=V1\nK2=V2',
'--devicetree=DDDDTTTT',
'--splash=splash',
'--pcrpkey=PATH',
'--uname=1.2.3',
'--stub=STUBPATH',
'--pcr-private-key=PKEY1',
'--pcr-public-key=PKEY2',
'--pcr-banks=SHA1,SHA256',
'--signing-engine=ENGINE',
'--secureboot-private-key=SBKEY',
'--secureboot-certificate=SBCERT',
'--sign-kernel',
'--no-sign-kernel',
'--tools=TOOLZ///',
'--output=OUTPUT',
'--measure',
'--no-measure',
])
assert opts.linux == pathlib.Path('/ARG1')
assert opts.initrd == [pathlib.Path('/ARG2'), pathlib.Path('/ARG3 WITH SPACE')]
assert opts.os_release == 'K1=V1\nK2=V2'
assert opts.devicetree == pathlib.Path('DDDDTTTT')
assert opts.splash == pathlib.Path('splash')
assert opts.pcrpkey == pathlib.Path('PATH')
assert opts.uname == '1.2.3'
assert opts.stub == pathlib.Path('STUBPATH')
assert opts.pcr_private_keys == [pathlib.Path('PKEY1')]
assert opts.pcr_public_keys == [pathlib.Path('PKEY2')]
assert opts.pcr_banks == ['SHA1', 'SHA256']
assert opts.signing_engine == 'ENGINE'
assert opts.sb_key == 'SBKEY'
assert opts.sb_cert == 'SBCERT'
assert opts.sign_kernel is False
assert opts.tools == pathlib.Path('TOOLZ/')
assert opts.output == pathlib.Path('OUTPUT')
assert opts.measure is False
def test_parse_sections():
opts = ukify.parse_args(
['/ARG1', '/ARG2',
'--section=test:TESTTESTTEST',
'--section=test2:@FILE',
])
assert opts.linux == pathlib.Path('/ARG1')
assert opts.initrd == [pathlib.Path('/ARG2')]
assert len(opts.sections) == 2
assert opts.sections[0].name == 'test'
assert isinstance(opts.sections[0].content, pathlib.Path)
assert opts.sections[0].tmpfile
assert opts.sections[0].offset is None
assert opts.sections[0].measure is False
assert opts.sections[1].name == 'test2'
assert opts.sections[1].content == pathlib.Path('FILE')
assert opts.sections[1].tmpfile is None
assert opts.sections[1].offset is None
assert opts.sections[1].measure is False
def test_help(capsys):
with pytest.raises(SystemExit):
ukify.parse_args(['--help'])
out = capsys.readouterr()
assert '--section' in out.out
assert not out.err
def test_help_error(capsys):
with pytest.raises(SystemExit):
ukify.parse_args(['a', 'b', '--no-such-option'])
out = capsys.readouterr()
assert not out.out
assert '--no-such-option' in out.err
assert len(out.err.splitlines()) == 1
@pytest.fixture(scope='session')
def kernel_initrd():
try:
text = subprocess.check_output(['bootctl', 'list', '--json=short'],
text=True)
except subprocess.CalledProcessError:
return None
items = json.loads(text)
for item in items:
try:
linux = f"{item['root']}{item['linux']}"
initrd = f"{item['root']}{item['initrd'][0]}"
except (KeyError, IndexError):
pass
return [linux, initrd]
else:
return None
def test_check_splash():
try:
# pyflakes: noqa
import PIL # noqa
except ImportError:
pytest.skip('PIL not available')
with pytest.raises(OSError):
ukify.check_splash(os.devnull)
def test_basic_operation(kernel_initrd, tmpdir):
if kernel_initrd is None:
pytest.skip('linux+initrd not found')
output = f'{tmpdir}/basic.efi'
opts = ukify.parse_args(kernel_initrd + [f'--output={output}'])
try:
ukify.check_inputs(opts)
except OSError as e:
pytest.skip(str(e))
ukify.make_uki(opts)
# let's check that objdump likes the resulting file
subprocess.check_output(['objdump', '-h', output])
def test_sections(kernel_initrd, tmpdir):
if kernel_initrd is None:
pytest.skip('linux+initrd not found')
output = f'{tmpdir}/basic.efi'
opts = ukify.parse_args([
*kernel_initrd,
f'--output={output}',
'--uname=1.2.3',
'--cmdline=ARG1 ARG2 ARG3',
'--os-release=K1=V1\nK2=V2\n',
'--section=.test:CONTENTZ',
])
try:
ukify.check_inputs(opts)
except OSError as e:
pytest.skip(str(e))
ukify.make_uki(opts)
# let's check that objdump likes the resulting file
dump = subprocess.check_output(['objdump', '-h', output], text=True)
for sect in 'text osrel cmdline linux initrd uname test'.split():
assert re.search(fr'^\s*\d+\s+.{sect}\s+0', dump, re.MULTILINE)
def unbase64(filename):
tmp = tempfile.NamedTemporaryFile()
base64.decode(filename.open('rb'), tmp)
tmp.flush()
return tmp
def test_uname_scraping(kernel_initrd):
if kernel_initrd is None:
pytest.skip('linux+initrd not found')
uname = ukify.Uname.scrape(kernel_initrd[0])
assert re.match(r'\d+\.\d+\.\d+', uname)
def test_efi_signing(kernel_initrd, tmpdir):
if kernel_initrd is None:
pytest.skip('linux+initrd not found')
if not shutil.which('sbsign'):
pytest.skip('sbsign not found')
ourdir = pathlib.Path(__file__).parent
cert = unbase64(ourdir / 'example.signing.crt.base64')
key = unbase64(ourdir / 'example.signing.key.base64')
output = f'{tmpdir}/signed.efi'
opts = ukify.parse_args([
*kernel_initrd,
f'--output={output}',
'--uname=1.2.3',
'--cmdline=ARG1 ARG2 ARG3',
f'--secureboot-certificate={cert.name}',
f'--secureboot-private-key={key.name}',
])
try:
ukify.check_inputs(opts)
except OSError as e:
pytest.skip(str(e))
ukify.make_uki(opts)
if shutil.which('sbverify'):
# let's check that sbverify likes the resulting file
dump = subprocess.check_output([
'sbverify',
'--cert', cert.name,
output,
], text=True)
assert 'Signature verification OK' in dump
def test_pcr_signing(kernel_initrd, tmpdir):
if kernel_initrd is None:
pytest.skip('linux+initrd not found')
if os.getuid() != 0:
pytest.skip('must be root to access tpm2')
if subprocess.call(['systemd-creds', 'has-tpm2', '-q']) != 0:
pytest.skip('tpm2 is not available')
ourdir = pathlib.Path(__file__).parent
pub = unbase64(ourdir / 'example.tpm2-pcr-public.pem.base64')
priv = unbase64(ourdir / 'example.tpm2-pcr-private.pem.base64')
output = f'{tmpdir}/signed.efi'
opts = ukify.parse_args([
*kernel_initrd,
f'--output={output}',
'--uname=1.2.3',
'--cmdline=ARG1 ARG2 ARG3',
'--os-release=ID=foobar\n',
'--pcr-banks=sha1', # use sha1 as that is most likely to be supported
f'--pcrpkey={pub.name}',
f'--pcr-public-key={pub.name}',
f'--pcr-private-key={priv.name}',
])
try:
ukify.check_inputs(opts)
except OSError as e:
pytest.skip(str(e))
ukify.make_uki(opts)
# let's check that objdump likes the resulting file
dump = subprocess.check_output(['objdump', '-h', output], text=True)
for sect in 'text osrel cmdline linux initrd uname pcrsig'.split():
assert re.search(fr'^\s*\d+\s+.{sect}\s+0', dump, re.MULTILINE)
# objcopy fails when called without an output argument (EPERM).
# It also fails when called with /dev/null (file truncated).
# It also fails when called with /dev/zero (because it reads the
# output file, infinitely in this case.)
# So let's just call it with a dummy output argument.
subprocess.check_call([
'objcopy',
*(f'--dump-section=.{n}={tmpdir}/out.{n}' for n in (
'pcrpkey', 'pcrsig', 'osrel', 'uname', 'cmdline')),
output,
tmpdir / 'dummy',
],
text=True)
assert open(tmpdir / 'out.pcrpkey').read() == open(pub.name).read()
assert open(tmpdir / 'out.osrel').read() == 'ID=foobar\n'
assert open(tmpdir / 'out.uname').read() == '1.2.3'
assert open(tmpdir / 'out.cmdline').read() == 'ARG1 ARG2 ARG3'
sig = open(tmpdir / 'out.pcrsig').read()
sig = json.loads(sig)
assert list(sig.keys()) == ['sha1']
assert len(sig['sha1']) == 4 # four items for four phases
def test_pcr_signing2(kernel_initrd, tmpdir):
if kernel_initrd is None:
pytest.skip('linux+initrd not found')
if os.getuid() != 0:
pytest.skip('must be root to access tpm2')
if subprocess.call(['systemd-creds', 'has-tpm2', '-q']) != 0:
pytest.skip('tpm2 is not available')
ourdir = pathlib.Path(__file__).parent
pub = unbase64(ourdir / 'example.tpm2-pcr-public.pem.base64')
priv = unbase64(ourdir / 'example.tpm2-pcr-private.pem.base64')
pub2 = unbase64(ourdir / 'example.tpm2-pcr-public2.pem.base64')
priv2 = unbase64(ourdir / 'example.tpm2-pcr-private2.pem.base64')
# simulate a microcode file
with open(f'{tmpdir}/microcode', 'wb') as microcode:
microcode.write(b'1234567890')
output = f'{tmpdir}/signed.efi'
opts = ukify.parse_args([
kernel_initrd[0], microcode.name, kernel_initrd[1],
f'--output={output}',
'--uname=1.2.3',
'--cmdline=ARG1 ARG2 ARG3',
'--os-release=ID=foobar\n',
'--pcr-banks=sha1', # use sha1 as that is most likely to be supported
f'--pcrpkey={pub2.name}',
f'--pcr-public-key={pub.name}',
f'--pcr-private-key={priv.name}',
'--phases=enter-initrd enter-initrd:leave-initrd',
f'--pcr-public-key={pub2.name}',
f'--pcr-private-key={priv2.name}',
'--phases=sysinit ready shutdown final', # yes, those phase paths are not reachable
])
try:
ukify.check_inputs(opts)
except OSError as e:
pytest.skip(str(e))
ukify.make_uki(opts)
# let's check that objdump likes the resulting file
dump = subprocess.check_output(['objdump', '-h', output], text=True)
for sect in 'text osrel cmdline linux initrd uname pcrsig'.split():
assert re.search(fr'^\s*\d+\s+.{sect}\s+0', dump, re.MULTILINE)
subprocess.check_call([
'objcopy',
*(f'--dump-section=.{n}={tmpdir}/out.{n}' for n in (
'pcrpkey', 'pcrsig', 'osrel', 'uname', 'cmdline', 'initrd')),
output,
tmpdir / 'dummy',
],
text=True)
assert open(tmpdir / 'out.pcrpkey').read() == open(pub2.name).read()
assert open(tmpdir / 'out.osrel').read() == 'ID=foobar\n'
assert open(tmpdir / 'out.uname').read() == '1.2.3'
assert open(tmpdir / 'out.cmdline').read() == 'ARG1 ARG2 ARG3'
assert open(tmpdir / 'out.initrd', 'rb').read(10) == b'1234567890'
sig = open(tmpdir / 'out.pcrsig').read()
sig = json.loads(sig)
assert list(sig.keys()) == ['sha1']
assert len(sig['sha1']) == 6 # six items for six phases paths
if __name__ == '__main__':
pytest.main([__file__, '-v'])

727
src/ukify/ukify.py Executable file
View file

@ -0,0 +1,727 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: LGPL-2.1+
# pylint: disable=missing-docstring,invalid-name,import-outside-toplevel
# pylint: disable=consider-using-with,unspecified-encoding,line-too-long
# pylint: disable=too-many-locals,too-many-statements,too-many-return-statements
# pylint: disable=too-many-branches
import argparse
import collections
import dataclasses
import fnmatch
import itertools
import json
import os
import pathlib
import re
import shlex
import subprocess
import tempfile
import typing
__version__ = '{{GIT_VERSION}}'
EFI_ARCH_MAP = {
# host_arch glob : [efi_arch, 32_bit_efi_arch if mixed mode is supported]
'x86_64' : ['x64', 'ia32'],
'i[3456]86' : ['ia32'],
'aarch64' : ['aa64'],
'arm[45678]*l' : ['arm'],
'riscv64' : ['riscv64'],
}
EFI_ARCHES: list[str] = sum(EFI_ARCH_MAP.values(), [])
def guess_efi_arch():
arch = os.uname().machine
for glob, mapping in EFI_ARCH_MAP.items():
if fnmatch.fnmatch(arch, glob):
efi_arch, *fallback = mapping
break
else:
raise ValueError(f'Unsupported architecture {arch}')
# This makes sense only on some architectures, but it also probably doesn't
# hurt on others, so let's just apply the check everywhere.
if fallback:
fw_platform_size = pathlib.Path('/sys/firmware/efi/fw_platform_size')
try:
size = fw_platform_size.read_text().strip()
except FileNotFoundError:
pass
else:
if int(size) == 32:
efi_arch = fallback[0]
print(f'Host arch {arch!r}, EFI arch {efi_arch!r}')
return efi_arch
def shell_join(cmd):
# TODO: drop in favour of shlex.join once shlex.join supports pathlib.Path.
return ' '.join(shlex.quote(str(x)) for x in cmd)
def pe_executable_size(filename):
import pefile
pe = pefile.PE(filename)
section = pe.sections[-1]
return section.VirtualAddress + section.Misc_VirtualSize
def round_up(x, blocksize=4096):
return (x + blocksize - 1) // blocksize * blocksize
def maybe_decompress(filename):
"""Decompress file if compressed. Return contents."""
f = open(filename, 'rb')
start = f.read(4)
f.seek(0)
if start.startswith(b'\x7fELF'):
# not compressed
return f.read()
if start.startswith(b'\x1f\x8b'):
import gzip
return gzip.open(f).read()
if start.startswith(b'\x28\xb5\x2f\xfd'):
import zstd
return zstd.uncompress(f.read())
if start.startswith(b'\x02\x21\x4c\x18'):
import lz4.frame
return lz4.frame.decompress(f.read())
if start.startswith(b'\x04\x22\x4d\x18'):
print('Newer lz4 stream format detected! This may not boot!')
import lz4.frame
return lz4.frame.decompress(f.read())
if start.startswith(b'\x89LZO'):
# python3-lzo is not packaged for Fedora
raise NotImplementedError('lzo decompression not implemented')
if start.startswith(b'BZh'):
import bz2
return bz2.open(f).read()
if start.startswith(b'\x5d\x00\x00'):
import lzma
return lzma.open(f).read()
raise NotImplementedError(f'unknown file format (starts with {start})')
class Uname:
# This class is here purely as a namespace for the functions
VERSION_PATTERN = r'(?P<version>[a-z0-9._-]+) \([^ )]+\) (?:#.*)'
NOTES_PATTERN = r'^\s+Linux\s+0x[0-9a-f]+\s+OPEN\n\s+description data: (?P<version>[0-9a-f ]+)\s*$'
# Linux version 6.0.8-300.fc37.ppc64le (mockbuild@buildvm-ppc64le-03.iad2.fedoraproject.org)
# (gcc (GCC) 12.2.1 20220819 (Red Hat 12.2.1-2), GNU ld version 2.38-24.fc37)
# #1 SMP Fri Nov 11 14:39:11 UTC 2022
TEXT_PATTERN = rb'Linux version (?P<version>\d\.\S+) \('
@classmethod
def scrape_x86(cls, filename, opts=None):
# Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L136
# and https://www.kernel.org/doc/html/latest/x86/boot.html#the-real-mode-kernel-header
with open(filename, 'rb') as f:
f.seek(0x202)
magic = f.read(4)
if magic != b'HdrS':
raise ValueError('Real-Mode Kernel Header magic not found')
f.seek(0x20E)
offset = f.read(1)[0] + f.read(1)[0]*256 # Pointer to kernel version string
f.seek(0x200 + offset)
text = f.read(128)
text = text.split(b'\0', maxsplit=1)[0]
text = text.decode()
if not (m := re.match(cls.VERSION_PATTERN, text)):
raise ValueError(f'Cannot parse version-host-release uname string: {text!r}')
return m.group('version')
@classmethod
def scrape_elf(cls, filename, opts=None):
readelf = find_tool('readelf', opts=opts)
cmd = [
readelf,
'--notes',
filename,
]
print('+', shell_join(cmd))
notes = subprocess.check_output(cmd, text=True)
if not (m := re.search(cls.NOTES_PATTERN, notes, re.MULTILINE)):
raise ValueError('Cannot find Linux version note')
text = ''.join(chr(int(c, 16)) for c in m.group('version').split())
return text.rstrip('\0')
@classmethod
def scrape_generic(cls, filename, opts=None):
# import libarchive
# libarchive-c fails with
# ArchiveError: Unrecognized archive format (errno=84, retcode=-30, archive_p=94705420454656)
# Based on https://gitlab.archlinux.org/archlinux/mkinitcpio/mkinitcpio/-/blob/master/functions#L209
text = maybe_decompress(filename)
if not (m := re.search(cls.TEXT_PATTERN, text)):
raise ValueError(f'Cannot find {cls.TEXT_PATTERN!r} in {filename}')
return m.group('version').decode()
@classmethod
def scrape(cls, filename, opts=None):
for func in (cls.scrape_x86, cls.scrape_elf, cls.scrape_generic):
try:
version = func(filename, opts=opts)
print(f'Found uname version: {version}')
return version
except ValueError as e:
print(str(e))
return None
@dataclasses.dataclass
class Section:
name: str
content: pathlib.Path
tmpfile: typing.IO | None = None
flags: list[str] | None = dataclasses.field(default=None)
offset: int | None = None
measure: bool = False
@classmethod
def create(cls, name, contents, flags=None, measure=False):
if isinstance(contents, str | bytes):
mode = 'wt' if isinstance(contents, str) else 'wb'
tmp = tempfile.NamedTemporaryFile(mode=mode, prefix=f'tmp{name}')
tmp.write(contents)
tmp.flush()
contents = pathlib.Path(tmp.name)
else:
tmp = None
return cls(name, contents, tmpfile=tmp, flags=flags, measure=measure)
@classmethod
def parse_arg(cls, s):
try:
name, contents, *rest = s.split(':')
except ValueError as e:
raise ValueError(f'Cannot parse section spec (name or contents missing): {s!r}') from e
if rest:
raise ValueError(f'Cannot parse section spec (extraneous parameters): {s!r}')
if contents.startswith('@'):
contents = pathlib.Path(contents[1:])
return cls.create(name, contents)
def size(self):
return self.content.stat().st_size
def check_name(self):
# PE section names with more than 8 characters are legal, but our stub does
# not support them.
if not self.name.isascii() or not self.name.isprintable():
raise ValueError(f'Bad section name: {self.name!r}')
if len(self.name) > 8:
raise ValueError(f'Section name too long: {self.name!r}')
@dataclasses.dataclass
class UKI:
executable: list[pathlib.Path|str]
sections: list[Section] = dataclasses.field(default_factory=list, init=False)
offset: int | None = dataclasses.field(default=None, init=False)
def __post_init__(self):
self.offset = round_up(pe_executable_size(self.executable))
def add_section(self, section):
assert self.offset
assert section.offset is None
if section.name in [s.name for s in self.sections]:
raise ValueError(f'Duplicate section {section.name}')
section.offset = self.offset
self.offset += round_up(section.size())
self.sections += [section]
def parse_banks(s):
banks = re.split(r',|\s+', s)
# TODO: do some sanity checking here
return banks
KNOWN_PHASES = (
'enter-initrd',
'leave-initrd',
'sysinit',
'ready',
'shutdown',
'final',
)
def parse_phase_paths(s):
# Split on commas or whitespace here. Commas might be hard to parse visually.
paths = re.split(r',|\s+', s)
for path in paths:
for phase in path.split(':'):
if phase not in KNOWN_PHASES:
raise argparse.ArgumentTypeError(f'Unknown boot phase {phase!r} ({path=})')
return paths
def check_splash(filename):
if filename is None:
return
# import is delayed, to avoid import when the splash image is not used
try:
from PIL import Image
except ImportError:
return
img = Image.open(filename, formats=['BMP'])
print(f'Splash image {filename} is {img.width}×{img.height} pixels')
def check_inputs(opts):
for name, value in vars(opts).items():
if name in {'output', 'tools'}:
continue
if not isinstance(value, pathlib.Path):
continue
# Open file to check that we can read it, or generate an exception
value.open().close()
check_splash(opts.splash)
def find_tool(name, fallback=None, opts=None):
if opts and opts.tools:
tool = opts.tools / name
if tool.exists():
return tool
return fallback or name
def combine_signatures(pcrsigs):
combined = collections.defaultdict(list)
for pcrsig in pcrsigs:
for bank, sigs in pcrsig.items():
for sig in sigs:
if sig not in combined[bank]:
combined[bank] += [sig]
return json.dumps(combined)
def call_systemd_measure(uki, linux, opts):
measure_tool = find_tool('systemd-measure',
'/usr/lib/systemd/systemd-measure',
opts=opts)
banks = opts.pcr_banks or ()
# PCR measurement
if opts.measure:
pp_groups = opts.phase_path_groups or []
cmd = [
measure_tool,
'calculate',
f'--linux={linux}',
*(f"--{s.name.removeprefix('.')}={s.content}"
for s in uki.sections
if s.measure),
*(f'--bank={bank}'
for bank in banks),
# For measurement, the keys are not relevant, so we can lump all the phase paths
# into one call to systemd-measure calculate.
*(f'--phase={phase_path}'
for phase_path in itertools.chain.from_iterable(pp_groups)),
]
print('+', shell_join(cmd))
subprocess.check_call(cmd)
# PCR signing
if opts.pcr_private_keys:
n_priv = len(opts.pcr_private_keys or ())
pp_groups = opts.phase_path_groups or [None] * n_priv
pub_keys = opts.pcr_public_keys or [None] * n_priv
pcrsigs = []
cmd = [
measure_tool,
'sign',
f'--linux={linux}',
*(f"--{s.name.removeprefix('.')}={s.content}"
for s in uki.sections
if s.measure),
*(f'--bank={bank}'
for bank in banks),
]
for priv_key, pub_key, group in zip(opts.pcr_private_keys,
pub_keys,
pp_groups):
extra = [f'--private-key={priv_key}']
if pub_key:
extra += [f'--public-key={pub_key}']
extra += [f'--phase={phase_path}' for phase_path in group or ()]
print('+', shell_join(cmd + extra))
pcrsig = subprocess.check_output(cmd + extra, text=True)
pcrsig = json.loads(pcrsig)
pcrsigs += [pcrsig]
combined = combine_signatures(pcrsigs)
uki.add_section(Section.create('.pcrsig', combined))
def join_initrds(initrds):
match initrds:
case []:
return None
case [initrd]:
return initrd
case multiple:
seq = []
for file in multiple:
initrd = file.read_bytes()
padding = b'\0' * round_up(len(initrd), 4) # pad to 32 bit alignment
seq += [initrd, padding]
return b''.join(seq)
assert False
def make_uki(opts):
# kernel payload signing
sbsign_tool = find_tool('sbsign', opts=opts)
sbsign_invocation = [
sbsign_tool,
'--key', opts.sb_key,
'--cert', opts.sb_cert,
]
if opts.signing_engine is not None:
sbsign_invocation += ['--engine', opts.signing_engine]
sign_kernel = opts.sign_kernel
if sign_kernel is None and opts.sb_key:
# figure out if we should sign the kernel
sbverify_tool = find_tool('sbverify', opts=opts)
cmd = [
sbverify_tool,
'--list',
opts.linux,
]
print('+', shell_join(cmd))
info = subprocess.check_output(cmd, text=True)
# sbverify has wonderful API
if 'No signature table present' in info:
sign_kernel = True
if sign_kernel:
linux_signed = tempfile.NamedTemporaryFile(prefix='linux-signed')
linux = linux_signed.name
cmd = [
*sbsign_invocation,
opts.linux,
'--output', linux,
]
print('+', shell_join(cmd))
subprocess.check_call(cmd)
else:
linux = opts.linux
if opts.uname is None:
print('Kernel version not specified, starting autodetection 😖.')
opts.uname = Uname.scrape(opts.linux, opts=opts)
uki = UKI(opts.stub)
initrd = join_initrds(opts.initrd)
# TODO: derive public key from from opts.pcr_private_keys?
pcrpkey = opts.pcrpkey
if pcrpkey is None:
if opts.pcr_public_keys and len(opts.pcr_public_keys) == 1:
pcrpkey = opts.pcr_public_keys[0]
sections = [
# name, content, measure?
('.osrel', opts.os_release, True ),
('.cmdline', opts.cmdline, True ),
('.dtb', opts.devicetree, True ),
('.splash', opts.splash, True ),
('.pcrpkey', pcrpkey, True ),
('.initrd', initrd, True ),
('.uname', opts.uname, False),
# linux shall be last to leave breathing room for decompression.
# We'll add it later.
]
for name, content, measure in sections:
if content:
uki.add_section(Section.create(name, content, measure=measure))
# systemd-measure doesn't know about those extra sections
for section in opts.sections:
uki.add_section(section)
# PCR measurement and signing
call_systemd_measure(uki, linux, opts=opts)
# UKI creation
uki.add_section(
Section.create('.linux', linux, measure=True,
flags=['code', 'readonly']))
if opts.sb_key:
unsigned = tempfile.NamedTemporaryFile(prefix='uki')
output = unsigned.name
else:
output = opts.output
objcopy_tool = find_tool('objcopy', opts=opts)
cmd = [
objcopy_tool,
opts.stub,
*itertools.chain.from_iterable(
('--add-section', f'{s.name}={s.content}',
'--change-section-vma', f'{s.name}=0x{s.offset:x}')
for s in uki.sections),
*itertools.chain.from_iterable(
('--set-section-flags', f"{s.name}={','.join(s.flags)}")
for s in uki.sections
if s.flags is not None),
output,
]
print('+', shell_join(cmd))
subprocess.check_call(cmd)
# UKI signing
if opts.sb_key:
cmd = [
*sbsign_invocation,
unsigned.name,
'--output', opts.output,
]
print('+', shell_join(cmd))
subprocess.check_call(cmd)
# We end up with no executable bits, let's reapply them
os.umask(umask := os.umask(0))
os.chmod(opts.output, 0o777 & ~umask)
print(f"Wrote {'signed' if opts.sb_key else 'unsigned'} {opts.output}")
def parse_args(args=None):
p = argparse.ArgumentParser(
description='Build and sign Unified Kernel Images',
allow_abbrev=False,
usage='''\
usage: ukify [options] linux initrd
ukify -h | --help
''')
# Suppress printing of usage synopsis on errors
p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
p.add_argument('linux',
type=pathlib.Path,
help='vmlinuz file [.linux section]')
p.add_argument('initrd',
type=pathlib.Path,
nargs='*',
help='initrd files [.initrd section]')
p.add_argument('--cmdline',
metavar='TEXT|@PATH',
help='kernel command line [.cmdline section]')
p.add_argument('--os-release',
metavar='TEXT|@PATH',
help='path to os-release file [.osrel section]')
p.add_argument('--devicetree',
metavar='PATH',
type=pathlib.Path,
help='Device Tree file [.dtb section]')
p.add_argument('--splash',
metavar='BMP',
type=pathlib.Path,
help='splash image bitmap file [.splash section]')
p.add_argument('--pcrpkey',
metavar='KEY',
type=pathlib.Path,
help='embedded public key to seal secrets to [.pcrpkey section]')
p.add_argument('--uname',
metavar='VERSION',
help='"uname -r" information [.uname section]')
p.add_argument('--efi-arch',
metavar='ARCH',
choices=('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
help='target EFI architecture')
p.add_argument('--stub',
type=pathlib.Path,
help='path the the sd-stub file [.text,.data,… sections]')
p.add_argument('--section',
dest='sections',
metavar='NAME:TEXT|@PATH',
type=Section.parse_arg,
action='append',
default=[],
help='additional section as name and contents [NAME section]')
p.add_argument('--pcr-private-key',
dest='pcr_private_keys',
metavar='PATH',
type=pathlib.Path,
action='append',
help='private part of the keypair for signing PCR signatures')
p.add_argument('--pcr-public-key',
dest='pcr_public_keys',
metavar='PATH',
type=pathlib.Path,
action='append',
help='public part of the keypair for signing PCR signatures')
p.add_argument('--phases',
dest='phase_path_groups',
metavar='PHASE-PATH…',
type=parse_phase_paths,
action='append',
help='phase-paths to create signatures for')
p.add_argument('--pcr-banks',
metavar='BANK…',
type=parse_banks)
p.add_argument('--signing-engine',
metavar='ENGINE',
help='OpenSSL engine to use for signing')
p.add_argument('--secureboot-private-key',
dest='sb_key',
help='path to key file or engine-specific designation for SB signing')
p.add_argument('--secureboot-certificate',
dest='sb_cert',
help='path to certificate file or engine-specific designation for SB signing')
p.add_argument('--sign-kernel',
action=argparse.BooleanOptionalAction,
help='Sign the embedded kernel')
p.add_argument('--tools',
type=pathlib.Path,
help='a directory with systemd-measure and other tools')
p.add_argument('--output', '-o',
type=pathlib.Path,
help='output file path')
p.add_argument('--measure',
action=argparse.BooleanOptionalAction,
help='print systemd-measure output for the UKI')
p.add_argument('--version',
action='version',
version=f'ukify {__version__}')
opts = p.parse_args(args)
if opts.cmdline and opts.cmdline.startswith('@'):
opts.cmdline = pathlib.Path(opts.cmdline[1:])
if opts.os_release is not None and opts.os_release.startswith('@'):
opts.os_release = pathlib.Path(opts.os_release[1:])
elif opts.os_release is None:
p = pathlib.Path('/etc/os-release')
if not p.exists():
p = pathlib.Path('/usr/lib/os-release')
opts.os_release = p
if opts.efi_arch is None:
opts.efi_arch = guess_efi_arch()
if opts.stub is None:
opts.stub = f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub'
if opts.signing_engine is None:
opts.sb_key = pathlib.Path(opts.sb_key) if opts.sb_key else None
opts.sb_cert = pathlib.Path(opts.sb_cert) if opts.sb_cert else None
if bool(opts.sb_key) ^ bool(opts.sb_cert):
raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
if opts.sign_kernel and not opts.sb_key:
raise ValueError('--sign-kernel requires --secureboot-private-key= and --secureboot-certificate= to be specified')
n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
if n_pcr_pub is not None and n_pcr_pub != n_pcr_priv:
raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
raise ValueError('--phases= specifications must match --pcr-private-key=')
if opts.output is None:
suffix = '.efi' if opts.sb_key else '.unsigned.efi'
opts.output = opts.linux.name + suffix
for section in opts.sections:
section.check_name()
return opts
def main():
opts = parse_args()
check_inputs(opts)
make_uki(opts)
if __name__ == '__main__':
main()

View file

@ -89,6 +89,7 @@ if install_tests
install_dir : testdata_dir)
endif
test_bootctl_json_sh = find_program('test-bootctl-json.sh')
test_fstab_generator_sh = find_program('test-fstab-generator.sh')
test_network_generator_conversion_sh = find_program('test-network-generator-conversion.sh')
test_systemctl_enable_sh = find_program('test-systemctl-enable.sh')

24
test/test-bootctl-json.sh Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LGPL-2.1-or-later
set -e
set -o pipefail
bootctl="${1:?}"
"$bootctl" --no-pager list >/dev/null || {
echo "$bootctl list failed, skipping tests" 1>&2
exit 77
}
set -x
"$bootctl" list --json=pretty | python3 -m json.tool >/dev/null
"$bootctl" list --json=short | python3 -m json.tool >/dev/null
command -v jq >/dev/null || {
echo "jq is not available, skipping jq tests" 1>&2
exit 0
}
"$bootctl" list --json=pretty | jq . >/dev/null
"$bootctl" list --json=short | jq . >/dev/null