Files
systemd/test/units/TEST-72-SYSUPDATE.sh
T
zouge666 eb020fc492 import: merge default OpenPGP keyrings
When an administrator installed a custom keyring under /etc,
signature verification selected it instead of the vendor keyring
under /usr. That could make vendor-signed system updates stop
verifying after adding local keys.

Collect all readable default keyrings, including the current and
legacy /etc paths, and pass temporary copies to GnuPG with
separate --keyring options. Keep SYSTEMD_OPENPGP_KEYRING as an
explicit override, add regression coverage for keyring
coexistence, override behavior, and current-path signing, and
silence the expected shellcheck false positive in the test
cleanup helper.

Closes: #40702
2026-08-26 11:35:55 +01:00

2116 lines
79 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# SPDX-License-Identifier: LGPL-2.1-or-later
# -*- mode: shell-script; indent-tabs-mode: nil; sh-basic-offset: 4; -*-
# ex: ts=8 sw=4 sts=4 et filetype=sh
set -eux
set -o pipefail
SYSUPDATE=/usr/bin/systemd-sysupdate
SYSUPDATED=/lib/systemd/systemd-sysupdated
UPDATECTL=""
VARLINK_SOCKET=/run/systemd/io.systemd.SysUpdate
SECTOR_SIZES=(512 4096)
WORKDIR="$(mktemp -d /var/tmp/test-72-XXXXXX)"
CONFIGDIR="/run/sysupdate.d"
BACKING_FILE="$WORKDIR/joined.raw"
export SYSTEMD_ESP_PATH="$WORKDIR/esp"
export SYSTEMD_XBOOTLDR_PATH="$WORKDIR/xbootldr"
export SYSTEMD_PAGER=cat
export SYSTEMD_LOG_LEVEL=debug
if [[ ! -x "$SYSUPDATE" ]]; then
echo "no systemd-sysupdate" >/skipped
exit 77
fi
if [[ -x "$SYSUPDATED" ]]; then
UPDATECTL="$(command -v updatectl || true)"
fi
# Loopback devices may not be supported. They are used because sfdisk cannot
# change the sector size of a file, and we want to test both 512 and 4096 byte
# sectors. If loopback devices are not supported, we can only test one sector
# size, and the underlying device is likely to have a sector size of 512 bytes.
if [[ ! -e /dev/loop-control ]]; then
echo "No loopback device support"
SECTOR_SIZES=(512)
fi
# Set up sysupdated and varlink drop-ins pointing at the correct definitions and
# setting no verification of images.
mkdir -p /run/systemd/system/systemd-sysupdated.service.d
cat >/run/systemd/system/systemd-sysupdated.service.d/override.conf<<EOF
[Service]
Environment=SYSTEMD_SYSUPDATE_NO_VERIFY=1
Environment=SYSTEMD_ESP_PATH=${SYSTEMD_ESP_PATH}
Environment=SYSTEMD_XBOOTLDR_PATH=${SYSTEMD_XBOOTLDR_PATH}
EOF
mkdir -p /run/systemd/system/systemd-sysupdate@.service.d
cat >/run/systemd/system/systemd-sysupdate@.service.d/override.conf<<EOF
[Service]
Environment=SYSTEMD_SYSUPDATE_NO_VERIFY=1
Environment=SYSTEMD_ESP_PATH=${SYSTEMD_ESP_PATH}
Environment=SYSTEMD_XBOOTLDR_PATH=${SYSTEMD_XBOOTLDR_PATH}
EOF
systemctl daemon-reload
SIGTEST_GPGHOME=
SIGTEST_OTHERHOME=
at_exit() {
set +e
systemctl stop test-sysupdate-notify-recorder.socket
rm -f /run/systemd/system/test-sysupdate-notify-recorder.socket \
/run/systemd/system/test-sysupdate-notify-recorder@.service
losetup -n --output NAME --associated "$BACKING_FILE" | while read -r loop_dev; do
losetup --detach "$loop_dev"
done
if [ "$SIGTEST_GPGHOME" != "" ]; then
gpgconf --homedir "$SIGTEST_GPGHOME" --kill all 2>/dev/null
fi
if [ "$SIGTEST_OTHERHOME" != "" ]; then
gpgconf --homedir "$SIGTEST_OTHERHOME" --kill all 2>/dev/null
fi
rm -rf "$WORKDIR"
}
trap at_exit EXIT
update_checksums() {
(cd "$WORKDIR/source" && rm -f BEST-BEFORE-* && sha256sum uki* part* dir-*.tar.gz linux* >SHA256SUMS)
}
update_checksums_with_best_before() {
(cd "$WORKDIR/source" && rm -f BEST-BEFORE-* && touch "BEST-BEFORE-$1" && sha256sum uki* part* dir-*.tar.gz linux* "BEST-BEFORE-$1" >SHA256SUMS)
}
new_version() {
local sector_size="${1:?}"
local version="${2:?}"
local corrupt="${3:-}"
# Create a pair of random partition payloads, and compress one.
# To make not the initial bytes of part1-xxx.raw accidentally match one of the compression header,
# let's make the first sector filled by zero.
dd if=/dev/zero of="$WORKDIR/source/part1-$version.raw" bs="$sector_size" count=1
dd if=/dev/urandom of="$WORKDIR/source/part1-$version.raw" bs="$sector_size" count=2047 conv=notrunc oflag=append
dd if=/dev/urandom of="$WORKDIR/source/part2-$version.raw" bs="$sector_size" count=2048
gzip -k -f "$WORKDIR/source/part2-$version.raw"
# Create a file payload and a suffixed version
echo $RANDOM >"$WORKDIR/source/linux-$version.erofs"
echo $RANDOM >"$WORKDIR/source/linux-$version.erofs.caibx"
# Create a random "UKI" payload
echo $RANDOM >"$WORKDIR/source/uki-$version.efi"
# Create a random extra payload
echo $RANDOM >"$WORKDIR/source/uki-extra-$version.efi"
# Create a random optional payload
echo $RANDOM >"$WORKDIR/source/optional-$version.efi"
# Create tarball of a directory
mkdir -p "$WORKDIR/source/dir-$version"
echo $RANDOM >"$WORKDIR/source/dir-$version/foo.txt"
echo $RANDOM >"$WORKDIR/source/dir-$version/bar.txt"
tar --numeric-owner -C "$WORKDIR/source/dir-$version/" -czf "$WORKDIR/source/dir-$version.tar.gz" .
if [[ "$corrupt" == "corrupt-checksum" ]]; then
# As requested, add a deliberately corrupt checksum for this file. This
# will get overwritten next time update_checksums() is called, but the
# integration test will probably have moved on to other things by then.
{
echo "abad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1dea part1-$version.raw"
echo "abad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1dea part2-$version.raw"
echo "abad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1dea part2-$version.raw.gz"
echo "abad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1dea linux-$version.erofs"
echo "abad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1dea linux-$version.erofs.caibx"
echo "abad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1dea uki-$version.efi"
echo "abad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1dea uki-extra-$version.efi"
echo "abad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1deaabad1dea dir-$version.tar.gz"
} >> "$WORKDIR/source/SHA256SUMS"
else
update_checksums
fi
}
check_no_new_update_available() {
local client="${1:?}"
local output
if [[ "$client" == "sysupdate-cli" ]]; then
(! "$SYSUPDATE" --verify=no check-new)
if output="$("$SYSUPDATE" --verify=no --json=short check-new)"; then
exit 1
fi
[[ "$output" == '{"available":null}' ]]
elif [[ "$client" == "varlink" ]]; then
(! varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.CheckNew '{"target":{"class":"host"}}') |& grep io.systemd.SysUpdate.NoUpdateNeeded >/dev/null
else
exit 1
fi
}
check_new_update_available() {
local client="${1:?}"
if [[ "$client" == "sysupdate-cli" ]]; then
"$SYSUPDATE" --verify=no check-new
elif [[ "$client" == "varlink" ]]; then
varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.CheckNew '{"target":{"class":"host"}}' | grep available
else
exit 1
fi
}
update_now() {
local update_type="${1:?}"
local client="${2:?}"
local checks="${3:-}"
# Update to newest version. First there should be an update ready, then we
# do the update, and then there should not be any ready anymore
#
# The update can either be done monolithically (by calling the `update`
# verb) or split (`acquire` then `update`). Both options are allowed for
# most updates in the test suite, so the test suite can be run to test both
# modes. Some updates in the test suite need to be monolithic (e.g. when
# repairing an installation), so that can be overridden via the local.
if [[ "$checks" != "no-checks" ]]; then
check_new_update_available "$client"
fi
if [[ "$update_type" == "monolithic" ]]; then
"$SYSUPDATE" --verify=no update
elif [[ "$update_type" == "split-offline" ]]; then
"$SYSUPDATE" --verify=no acquire
"$SYSUPDATE" --verify=no update --offline
elif [[ "$update_type" == "split" ]]; then
"$SYSUPDATE" --verify=no acquire
"$SYSUPDATE" --verify=no update
elif [[ "$update_type" == "updatectl" ]]; then
if [[ -x "$UPDATECTL" ]]; then
systemctl start systemd-sysupdated
"$UPDATECTL" update
else
# Gracefully fall back to sysupdate
"$SYSUPDATE" --verify=no update
fi
else
exit 1
fi
if [[ "$checks" != "no-checks" ]]; then
check_no_new_update_available "$client"
fi
}
verify_version() {
local block_device="${1:?}"
local sector_size="${2:?}"
local version="${3:?}"
local part1_number="${4:?}"
local gpt_reserved_sectors part2_number part1_offset part2_offset
part2_number=$(( part1_number + 2 ))
gpt_reserved_sectors=$((1024 * 1024 / sector_size))
part1_offset=$(((part1_number - 1) * 2048 + gpt_reserved_sectors))
part2_offset=$(((part2_number - 1) * 2048 + gpt_reserved_sectors))
# Check the partitions
dd if="$block_device" bs="$sector_size" skip="$part1_offset" count=2048 | cmp "$WORKDIR/source/part1-$version.raw"
dd if="$block_device" bs="$sector_size" skip="$part2_offset" count=2048 | cmp "$WORKDIR/source/part2-$version.raw"
# Check the UKI
cmp "$WORKDIR/source/uki-$version.efi" "$WORKDIR/xbootldr/EFI/Linux/uki_$version+3-0.efi"
test -z "$(ls -A "$WORKDIR/esp/EFI/Linux")"
# Check the extra efi
cmp "$WORKDIR/source/uki-extra-$version.efi" "$WORKDIR/xbootldr/EFI/Linux/uki_$version.efi.extra.d/extra.addon.efi"
# Check the regular file and its suffixed version
cmp "$WORKDIR/source/linux-$version.erofs" "$WORKDIR/system/linux-$version.erofs"
cmp "$WORKDIR/source/linux-$version.erofs.caibx" "$WORKDIR/system/linux-$version.erofs.caibx"
}
verify_version_current() {
local version="${3:?}"
verify_version "$@"
# Check the directories
cmp "$WORKDIR/source/dir-$version/foo.txt" "$WORKDIR/dirs/current/foo.txt"
cmp "$WORKDIR/source/dir-$version/bar.txt" "$WORKDIR/dirs/current/bar.txt"
}
verify_object_fields() {
local updatectl_output="${1:?}"
[[ "${updatectl_output}" != *"Unrecognized object field"* ]]
}
for sector_size in "${SECTOR_SIZES[@]}"; do
for client in sysupdate-cli varlink; do
for update_type in monolithic split-offline split updatectl; do
# Disk size of:
# - 1MB for GPT
# - 4 partitions of 2048 sectors each
# - 1MB for backup GPT
disk_size=$((sector_size * 2048 * 4 + 1024 * 1024 * 2))
rm -f "$BACKING_FILE"
truncate -s "$disk_size" "$BACKING_FILE"
if [[ -e /dev/loop-control ]]; then
blockdev="$(losetup --find --show --sector-size "$sector_size" "$BACKING_FILE")"
else
blockdev="$BACKING_FILE"
fi
sfdisk "$blockdev" <<EOF
label: gpt
unit: sectors
sector-size: $sector_size
size=2048, type=4f68bce3-e8cd-4db1-96e7-fbcaf984b709, name=_empty
size=2048, type=4f68bce3-e8cd-4db1-96e7-fbcaf984b709, name=_empty
size=2048, type=2c7357ed-ebd2-46d9-aec1-23d437ec2bf5, name=_empty
size=2048, type=2c7357ed-ebd2-46d9-aec1-23d437ec2bf5, name=_empty
EOF
for d in "$WORKDIR/dirs" "$CONFIGDIR"; do
rm -rf "$d"
mkdir -p "$d"
done
cat >"$CONFIGDIR/01-first.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=part1-@v.raw
[Target]
Type=partition
Path=$blockdev
MatchPattern=part1-@v
MatchPartitionType=root-x86-64
EOF
cat >"$CONFIGDIR/02-second.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=part2-@v.raw.gz
[Target]
Type=partition
Path=$blockdev
MatchPattern=a-very-long-partition-name-@v
MatchPartitionType=root-x86-64-verity
EOF
cat >"$CONFIGDIR/03-third.transfer" <<EOF
[Source]
Type=directory
Path=$WORKDIR/source
MatchPattern=dir-@v
[Target]
Type=directory
Path=$WORKDIR/dirs
CurrentSymlink=$WORKDIR/dirs/current
MatchPattern=dir-@v
InstancesMax=3
EOF
cat >"$CONFIGDIR/04-fourth.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=uki-@v.efi
[Target]
Type=regular-file
Path=/EFI/Linux
PathRelativeTo=boot
MatchPattern=uki_@v+@l-@d.efi \
uki_@v+@l.efi \
uki_@v.efi
Mode=0444
TriesLeft=3
TriesDone=0
InstancesMax=2
EOF
cat >"$CONFIGDIR/05-fifth.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=uki-extra-@v.efi
[Target]
Type=regular-file
Path=/EFI/Linux
PathRelativeTo=boot
MatchPattern=uki_@v.efi.extra.d/extra.addon.efi
Mode=0444
InstancesMax=2
EOF
# Test with a transfer which contains one of the other transfers as a prefix
# of its files, to check pattern matching can distinguish the two.
cat >"$CONFIGDIR/06-sixth.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=linux-@v.erofs
[Target]
Type=regular-file
Path=$WORKDIR/system
MatchPattern=linux-@v.erofs
ReadOnly=yes
InstancesMax=4
EOF
cat >"$CONFIGDIR/07-seventh.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=linux-@v.erofs.caibx
[Target]
Type=regular-file
Path=$WORKDIR/system
MatchPattern=linux-@v.erofs.caibx
ReadOnly=yes
InstancesMax=4
EOF
cat >"$CONFIGDIR/optional.feature" <<EOF
[Feature]
Description=Optional Feature
EOF
cat >"$CONFIGDIR/99-optional.transfer" <<EOF
[Transfer]
Features=optional
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=optional-@v.efi
[Target]
Type=regular-file
Path=/EFI/Linux
PathRelativeTo=boot
MatchPattern=uki_@v.efi.extra.d/optional.efi
Mode=0444
InstancesMax=2
EOF
rm -rf "${WORKDIR:?}"/{esp,xbootldr,source,system}
mkdir -p "$WORKDIR"/{source,esp/EFI/Linux,xbootldr/EFI/Linux,system}
# Install initial version and verify
new_version "$sector_size" v1
update_now "$update_type" "$client"
verify_version_current "$blockdev" "$sector_size" v1 1
# Create second version, update and verify that it is added
new_version "$sector_size" v2
update_now "$update_type" "$client"
verify_version "$blockdev" "$sector_size" v1 1
verify_version_current "$blockdev" "$sector_size" v2 2
# Create third version, update and verify it replaced the first version
new_version "$sector_size" v3
update_now "$update_type" "$client"
verify_version_current "$blockdev" "$sector_size" v3 1
verify_version "$blockdev" "$sector_size" v2 2
test ! -f "$WORKDIR/xbootldr/EFI/Linux/uki_v1+3-0.efi"
test ! -f "$WORKDIR/xbootldr/EFI/Linux/uki_v1.efi.extra.d/extra.addon.efi"
test ! -d "$WORKDIR/xbootldr/EFI/Linux/uki_v1.efi.extra.d"
# Create fourth version, but make it be incomplete (i.e. missing some files)
# on the server-side. Verify that it's not offered as an update.
new_version "$sector_size" v4
rm "$WORKDIR/source/uki-extra-v4.efi"
update_checksums
check_no_new_update_available "$client"
# Create a fifth version, that's complete on the server side. We should
# completely skip the incomplete v4 and install v5 instead.
new_version "$sector_size" v5
update_now "$update_type" "$client"
verify_version "$blockdev" "$sector_size" v3 1
verify_version_current "$blockdev" "$sector_size" v5 2
# Make the local installation of v5 incomplete by deleting a file, then make
# sure that sysupdate still recognizes the installation and can complete it
# in place
# Always do this as a monolithic update for the repair to work.
rm -r "$WORKDIR/xbootldr/EFI/Linux/uki_v5.efi.extra.d"
"$SYSUPDATE" --offline list v5 | grep "incomplete" >/dev/null
update_now "monolithic" "$client"
"$SYSUPDATE" --offline list v5 | grep -v "incomplete" >/dev/null
verify_version "$blockdev" "$sector_size" v3 1
verify_version_current "$blockdev" "$sector_size" v5 2
# Now let's try enabling an optional feature
if [[ "$client" == "sysupdate-cli" ]]; then
"$SYSUPDATE" features | grep "optional"
"$SYSUPDATE" features optional | grep "99-optional"
elif [[ "$client" == "varlink" ]]; then
[[ $(varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListFeatures '{"target":{"class":"host"}}' | jq -r '.features[] | select(.id=="optional") | .description') == "Optional Feature" ]]
varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListFeatures '{"target":{"class":"host"}}' | jq -r '.features[] | select(.id=="optional") | .transfers' | grep "99-optional"
else
exit 1
fi
test ! -f "$WORKDIR/xbootldr/EFI/Linux/uki_v5.efi.extra.d/optional.efi"
mkdir "$CONFIGDIR/optional.feature.d"
echo -e "[Feature]\nEnabled=true" > "$CONFIGDIR/optional.feature.d/enable.conf"
"$SYSUPDATE" --offline list v5 | grep "incomplete" >/dev/null
update_now "$update_type" "$client"
"$SYSUPDATE" --offline list v5 | grep -v "incomplete" >/dev/null
verify_version "$blockdev" "$sector_size" v3 1
verify_version_current "$blockdev" "$sector_size" v5 2
test -f "$WORKDIR/xbootldr/EFI/Linux/uki_v5.efi.extra.d/optional.efi"
# And now let's disable it and make sure it gets cleaned up
rm -r "$CONFIGDIR/optional.feature.d"
check_no_new_update_available "$client"
"$SYSUPDATE" vacuum
"$SYSUPDATE" --offline list v5 | grep -v "incomplete" >/dev/null
verify_version "$blockdev" "$sector_size" v3 1
verify_version_current "$blockdev" "$sector_size" v5 2
test ! -f "$WORKDIR/xbootldr/EFI/Linux/uki_v5.efi.extra.d/optional.efi"
# Create sixth version, update using updatectl and verify it replaced the
# correct version
new_version "$sector_size" v6
if [[ -x "$UPDATECTL" ]]; then
systemctl start systemd-sysupdated
check_new_update_available "$client"
"$UPDATECTL" update |& tee "$WORKDIR"/updatectl-update-6
grep "Done" "$WORKDIR"/updatectl-update-6
(! grep "Already up-to-date" "$WORKDIR"/updatectl-update-6)
else
# If no updatectl, gracefully fall back to systemd-sysupdate
update_now "$update_type" "$client"
fi
# User-facing updatectl returns 0 if there's no updates, so use the low-level
# utility to make sure we did upgrade
check_no_new_update_available "$client"
verify_version_current "$blockdev" "$sector_size" v6 1
verify_version "$blockdev" "$sector_size" v5 2
# Next, let's run updatectl's various inspection commands. We're not
# testing for specific output, but this will at least catch obvious crashes
# and allow updatectl to run under the various sanitizers. We create a
# component so that updatectl has multiple targets to list.
if [[ -x "$UPDATECTL" ]]; then
mkdir -p /run/sysupdate.test.d/
cp "$CONFIGDIR/01-first.transfer" /run/sysupdate.test.d/01-first.transfer
verify_object_fields "$("$UPDATECTL" list 2>&1)"
verify_object_fields "$("$UPDATECTL" list host 2>&1)"
verify_object_fields "$("$UPDATECTL" list host@v6 2>&1)"
"$UPDATECTL" check
rm -r /run/sysupdate.test.d
fi
# Create seventh version, and update through a file:// URL. This should be
# almost as good as testing HTTP, but is simpler for us to set up. file:// is
# abstracted in curl for us, and since our main goal is to test our own code
# (and not curl) this test should be quite good even if not comprehensive. This
# will test the SHA256SUMS logic at least (we turn off GPG validation though,
# see above)
new_version "$sector_size" v7
cat >"$CONFIGDIR/02-second.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/source
MatchPattern=part2-@v.raw.gz
[Target]
Type=partition
Path=$blockdev
MatchPattern=a-very-long-partition-name-@v
MatchPartitionType=root-x86-64-verity
EOF
cat >"$CONFIGDIR/03-third.transfer" <<EOF
[Source]
Type=url-tar
Path=file://$WORKDIR/source
MatchPattern=dir-@v.tar.gz
[Target]
Type=directory
Path=$WORKDIR/dirs
CurrentSymlink=$WORKDIR/dirs/current
MatchPattern=dir-@v
InstancesMax=3
EOF
update_now "$update_type" "$client"
verify_version "$blockdev" "$sector_size" v6 1
verify_version_current "$blockdev" "$sector_size" v7 2
# Check with a best before in the past
update_checksums_with_best_before "$(date -u +'%Y-%m-%d' -d 'last month')"
(! "$SYSUPDATE" --verify=no update)
# Retry but force check off
SYSTEMD_SYSUPDATE_VERIFY_FRESHNESS=0 "$SYSUPDATE" --verify=no update
# Check with best before in the future
update_checksums_with_best_before "$(date -u +'%Y-%m-%d' -d 'next month')"
"$SYSUPDATE" --verify=no update
# Check again without a best before
update_checksums
"$SYSUPDATE" --verify=no update
# Let's make sure that we don't break our backwards-compat for .conf files
# (what .transfer files were called before v257)
for i in "$CONFIGDIR/"*.conf; do echo mv "$i" "${i%.conf}.transfer"; done
new_version "$sector_size" v8
update_now "$update_type" "$client"
verify_version_current "$blockdev" "$sector_size" v8 1
verify_version "$blockdev" "$sector_size" v7 2
# Create a 9th version but corrupt the checksum in SHA256SUMS so pulling it
# fails when verifying the checksum, in order to create a current+partial
# state. Try to update again and verify that this results in an error.
# Vacuum the partial version, regenerate it on the server, try updating
# again and it should succeed.
new_version "$sector_size" v9 "corrupt-checksum"
(! update_now "$update_type" "$client")
"$SYSUPDATE" --offline list v9 | grep "partial" >/dev/null
verify_version_current "$blockdev" "$sector_size" v8 1
# don’t verify the other part of the block device as it’s in an indeterminate state
(! update_now "$update_type" "$client" "no-checks") |& tee "$WORKDIR"/update_now-9
cat "$WORKDIR"/update_now-9
grep "is already acquired and partially installed. Vacuum it to try installing again." "$WORKDIR"/update_now-9
"$SYSUPDATE" --offline vacuum |& grep "Removing old partial" >/dev/null
verify_version_current "$blockdev" "$sector_size" v8 1
# don’t verify the other part of the block device as it’s in an indeterminate state
"$SYSUPDATE" --verify=no list v9 | grep "candidate" >/dev/null
new_version "$sector_size" v9
update_now "$update_type" "$client"
verify_version "$blockdev" "$sector_size" v8 1
verify_version_current "$blockdev" "$sector_size" v9 2
# Test that checking for an update on a non-existent target fails
# (for backwards compatibility reasons, the validation in sysupdate-cli is
# less strict)
if [[ "$client" == "sysupdate-cli" ]]; then
(! "$SYSUPDATE" --verify=no check-new --component=../) |& grep "Component name invalid" >/dev/null
elif [[ "$client" == "varlink" ]]; then
(! varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.CheckNew '{"target":{"class":"component","name":"../"}}') |& grep org.varlink.service.InvalidParameter >/dev/null
(! varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.CheckNew '{"target":{"class":"component","name":"doesnotexist"}}') |& grep io.systemd.SysUpdate.NoSuchTarget >/dev/null
else
exit 1
fi
# Cleanup
[[ -b "$blockdev" ]] && losetup --detach "$blockdev"
rm "$BACKING_FILE"
done
done
done
# Regression test for https://github.com/systemd/systemd/issues/41501 — check
# that a ‘default’ component is only listed by sysupdate if it’s fully configured
mv "$CONFIGDIR" "$CONFIGDIR.backup"
mkdir -p /run/sysupdate.some-component.d
tee /run/sysupdate.some-component.d/portable.transfer << EOF
[Transfer]
ChangeLog=https://example.com/changelog/@v
Verify=no
[Source]
Type=url-tar
Path=https://example.com/does-not-matter/@v.tar.xz
MatchPattern=some-component_@v-portable.tar.xz
[Target]
Type=directory
Path=/var/lib/portables
MatchPattern=some-component_@v
CurrentSymlink=some-component
EOF
"$SYSUPDATE" --json=short components | grep -F '{"default":false,"components":["some-component"]}' >/dev/null
varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListTargets | jq -e '.targets | all(.id.class != "host")' >/dev/null
mkdir /run/sysupdate.d
"$SYSUPDATE" --json=short components | grep -F '{"default":false,"components":["some-component"]}' >/dev/null
varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListTargets | jq -e '.targets | all(.id.class != "host")' >/dev/null
[[ $(varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListTargets | jq -r '.targets[0].id.name') == "some-component" ]]
# Regression test for https://github.com/systemd/systemd/issues/42330 — the
# 'pending'/'reboot' verbs and the '--reboot' switch compare the newest installed
# version against the booted OS version (IMAGE_VERSION= from os-release), which is
# unrelated to component versions. Selecting a component must therefore be refused
# rather than silently performing a bogus comparison.
(! "$SYSUPDATE" --component=some-component pending) |& grep -F 'may not be combined' >/dev/null
(! "$SYSUPDATE" --component=some-component reboot) |& grep -F 'may not be combined' >/dev/null
(! "$SYSUPDATE" --component=some-component update --reboot) |& grep -F 'may not be combined' >/dev/null
# Clean up regression test
rmdir /run/sysupdate.d
rm -rf /run/sysupdate.some-component.d
mv "$CONFIGDIR.backup" "$CONFIGDIR"
# Make sure the processing of compressed streams still handles uncompressed streams shorter than
# COMPRESSION_MAGIC_BYTES_MAX correctly.
rm -rf "$CONFIGDIR" "$WORKDIR/blobs"
mkdir -p "$CONFIGDIR" "$WORKDIR/blobs"
printf 'xx' >"$WORKDIR/source/tiny-v1.bin"
(cd "$WORKDIR/source" && sha256sum tiny-v1.bin >SHA256SUMS)
cat >"$CONFIGDIR/01-tiny-url.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/source
MatchPattern=tiny-@v.bin
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=tiny-@v.bin
InstancesMax=1
EOF
"$SYSUPDATE" --verify=no update
cmp "$WORKDIR/source/tiny-v1.bin" "$WORKDIR/blobs/tiny-v1.bin"
# Test that listing features when none are configured gives an empty list.
"$SYSUPDATE" features |& grep "No features." >/dev/null
[[ $(varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListFeatures '{"target":{"class":"host"}}' | jq -r '.features') == "[]" ]]
# Test that definitions found through --root= are not prefixed twice.
rm -rf "$WORKDIR/root-definitions"
mkdir -p "$WORKDIR/root-definitions/etc/sysupdate.d" \
"$WORKDIR/root-definitions/etc" \
"$WORKDIR/root-definitions/source" \
"$WORKDIR/root-definitions/target"
printf '%s\n' 'ID=repro' 'VERSION_ID=1' >"$WORKDIR/root-definitions/etc/os-release"
cat >"$WORKDIR/root-definitions/etc/sysupdate.d/rootfeat.feature" <<EOF
[Feature]
Description=Root Feature
EOF
cat >"$WORKDIR/root-definitions/etc/sysupdate.d/01-root.transfer" <<EOF
[Transfer]
[Source]
Type=regular-file
Path=/source
MatchPattern=root-@v.bin
[Target]
Type=regular-file
Path=/target
MatchPattern=root-@v.bin
InstancesMax=2
EOF
"$SYSUPDATE" \
--root="$WORKDIR/root-definitions" \
--verify=no \
--offline \
features rootfeat \
| grep -F "Root Feature" >/dev/null
rm -rf "$WORKDIR/root-definitions"
# Cleanup
rm "$CONFIGDIR/01-tiny-url.transfer"
rm "$WORKDIR/source/tiny-v1.bin"
rm "$WORKDIR/source/SHA256SUMS"
# Check that malformed manifest hashes are rejected without aborting.
rm -rf "$WORKDIR/malformed-manifest"
mkdir -p "$WORKDIR/malformed-manifest/definitions" "$WORKDIR/malformed-manifest/source" "$WORKDIR/malformed-manifest/target"
printf 'payload\n' >"$WORKDIR/malformed-manifest/source/malformed-v1.bin"
hash_64=0000000000000000000000000000000000000000000000000000000000000000
printf '%s\t\t *malformed-v1.bin\n' "${hash_64%??}" >"$WORKDIR/malformed-manifest/source/SHA256SUMS"
cat >"$WORKDIR/malformed-manifest/definitions/01-malformed-hash.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/malformed-manifest/source
MatchPattern=malformed-@v.bin
[Target]
Type=regular-file
Path=$WORKDIR/malformed-manifest/target
MatchPattern=malformed-@v.bin
InstancesMax=1
EOF
set +e
"$SYSUPDATE" --definitions="$WORKDIR/malformed-manifest/definitions" --verify=no check-new &>"$WORKDIR/malformed-manifest/check-new.log"
rc=$?
set -e
[[ $rc -ne 0 ]]
[[ $rc -ne 134 ]]
grep -F "Manifest hash at line 1 decoded to 31 bytes" "$WORKDIR/malformed-manifest/check-new.log" >/dev/null
# Check the "cleanup" verb and the underlying install database. A successful
# update must record an install database entry for every transfer that installs
# into the file system, and those entries must cover all installed resources
# (regular files as well as directories). Once the transfer file owning some
# resources is removed, "systemd-sysupdate cleanup" must delete the now-orphaned
# resources (and their install database entries), while leaving resources that
# are still owned by a transfer file untouched.
INSTALLDB="/var/lib/systemd/sysupdate/installdb"
CLEANUP="$WORKDIR/cleanup"
rm -rf "$CONFIGDIR" "$INSTALLDB" "$CLEANUP"
mkdir -p "$CONFIGDIR" "$CLEANUP/source" "$CLEANUP/target"
# The "alpha" transfer installs plain regular files, while the "beta" transfer
# installs whole directories (each populated with a couple of files), to exercise
# the recursive removal of orphaned directory resources during cleanup.
cleanup_new_version() {
local version="${1:?}"
echo "$RANDOM" >"$CLEANUP/source/alpha-$version.bin"
rm -rf "$CLEANUP/source/beta-$version"
mkdir -p "$CLEANUP/source/beta-$version"
echo "$RANDOM" >"$CLEANUP/source/beta-$version/one.txt"
echo "$RANDOM" >"$CLEANUP/source/beta-$version/two.txt"
(cd "$CLEANUP/source" && sha256sum alpha-* >SHA256SUMS)
}
# Number of install database entries (symlinks) currently recorded.
installdb_count() {
if [[ -d "$INSTALLDB" ]]; then
find "$INSTALLDB" -mindepth 1 -maxdepth 1 -type l | wc -l
else
echo 0
fi
}
# Assert that every resource (file or directory) currently installed in the
# target directory is covered by at least one install database entry (i.e. its
# name matches a recorded pattern that points at the target directory).
assert_installdb_covers_target() {
local f base link tgt path pattern glob covered
for f in "$CLEANUP/target"/*; do
[[ -e "$f" ]] || continue
base="$(basename "$f")"
covered=0
while read -r link; do
tgt="$(readlink "$link")"
# Entries are stored as "<path>/./<pattern>".
path="${tgt%%/./*}"
pattern="${tgt#*/./}"
# Translate the sysupdate pattern into a shell glob (only @v is used here).
glob="${pattern//@v/*}"
# shellcheck disable=SC2053
if [[ "$path" == "$CLEANUP/target" && "$base" == $glob ]]; then
covered=1
break
fi
done < <(find "$INSTALLDB" -mindepth 1 -maxdepth 1 -type l)
[[ "$covered" -eq 1 ]] || { echo "Installed resource '$f' not covered by install database" >&2; exit 1; }
done
}
# Verify the installed beta-<version> directory matches its source.
verify_beta_synced() {
local version="${1:?}"
test -d "$CLEANUP/target/beta-$version"
cmp "$CLEANUP/source/beta-$version/one.txt" "$CLEANUP/target/beta-$version/one.txt"
cmp "$CLEANUP/source/beta-$version/two.txt" "$CLEANUP/target/beta-$version/two.txt"
}
cat >"$CONFIGDIR/01-alpha.transfer" <<EOF
[Source]
Type=regular-file
Path=$CLEANUP/source
MatchPattern=alpha-@v.bin
[Target]
Type=regular-file
Path=$CLEANUP/target
MatchPattern=alpha-@v.bin
InstancesMax=2
EOF
cat >"$CONFIGDIR/02-beta.transfer" <<EOF
[Source]
Type=directory
Path=$CLEANUP/source
MatchPattern=beta-@v
[Target]
Type=directory
Path=$CLEANUP/target
MatchPattern=beta-@v
InstancesMax=2
EOF
# Install two versions; with InstancesMax=2 both are kept for each transfer.
cleanup_new_version v1
"$SYSUPDATE" --verify=no update
cleanup_new_version v2
"$SYSUPDATE" --verify=no update
# All four resources must be installed, and the directory resources must have
# been synced over completely.
test -f "$CLEANUP/target/alpha-v1.bin"
test -f "$CLEANUP/target/alpha-v2.bin"
verify_beta_synced v1
verify_beta_synced v2
# The update must have recorded one install database entry per transfer pattern,
# and those entries must cover every installed resource.
[[ "$(installdb_count)" -eq 2 ]]
assert_installdb_covers_target
# Running cleanup while all transfer files are still in place must be a no-op:
# nothing is orphaned, so nothing must be removed.
"$SYSUPDATE" cleanup
test -f "$CLEANUP/target/alpha-v1.bin"
test -f "$CLEANUP/target/alpha-v2.bin"
verify_beta_synced v1
verify_beta_synced v2
[[ "$(installdb_count)" -eq 2 ]]
assert_installdb_covers_target
# Remove the transfer file owning the "beta" directories and clean up. The beta
# directories (with all their contents) and their install database entry must be
# removed, while the alpha files must be kept since their transfer file is still
# in place.
rm "$CONFIGDIR/02-beta.transfer"
"$SYSUPDATE" cleanup
test -f "$CLEANUP/target/alpha-v1.bin"
test -f "$CLEANUP/target/alpha-v2.bin"
test ! -e "$CLEANUP/target/beta-v1"
test ! -e "$CLEANUP/target/beta-v2"
[[ "$(installdb_count)" -eq 1 ]]
assert_installdb_covers_target
# Now remove the remaining transfer file and clean up again. The alpha files and
# the last install database entry must be removed too.
rm "$CONFIGDIR/01-alpha.transfer"
"$SYSUPDATE" cleanup
test ! -f "$CLEANUP/target/alpha-v1.bin"
test ! -f "$CLEANUP/target/alpha-v2.bin"
[[ "$(installdb_count)" -eq 0 ]]
rm -rf "$CONFIGDIR" "$INSTALLDB" "$CLEANUP"
# Under --root=, cleanup must compare current transfer target paths in the same
# root-relative form stored in the install database, and keep still-owned files.
ROOT_CLEANUP="$WORKDIR/root-cleanup"
ROOT_CLEANUP_ROOT="$ROOT_CLEANUP/root"
ROOT_CLEANUP_DB_VALUE="/target/./alpha-@v.bin"
ROOT_CLEANUP_DB_KEY="$(printf '%s' "$ROOT_CLEANUP_DB_VALUE" | sha256sum | cut -d' ' -f1)"
rm -rf "$ROOT_CLEANUP"
mkdir -p "$ROOT_CLEANUP_ROOT/etc/sysupdate.d" \
"$ROOT_CLEANUP_ROOT/target" \
"$ROOT_CLEANUP_ROOT/var/lib/systemd/sysupdate/installdb"
cat >"$ROOT_CLEANUP_ROOT/etc/sysupdate.d/01-alpha.transfer" <<EOF
[Source]
Type=regular-file
Path=/source
MatchPattern=alpha-@v.bin
[Target]
Type=regular-file
Path=/target
MatchPattern=alpha-@v.bin
InstancesMax=2
EOF
echo "$RANDOM" >"$ROOT_CLEANUP_ROOT/target/alpha-v1.bin"
ln -s "$ROOT_CLEANUP_DB_VALUE" \
"$ROOT_CLEANUP_ROOT/var/lib/systemd/sysupdate/installdb/$ROOT_CLEANUP_DB_KEY"
"$SYSUPDATE" --root="$ROOT_CLEANUP_ROOT" --verify=no cleanup
test -f "$ROOT_CLEANUP_ROOT/target/alpha-v1.bin"
test -L "$ROOT_CLEANUP_ROOT/var/lib/systemd/sysupdate/installdb/$ROOT_CLEANUP_DB_KEY"
rm -rf "$ROOT_CLEANUP"
# Briefly check the "--component-all" switch of the "cleanup" verb. Each component
# keeps its own install database (installdb.<component>), and "cleanup
# --component-all" must clean up orphaned resources across *all* of them in one
# go. Set up two components, install a resource into each, then drop both transfer
# files and run a single "cleanup --component-all" — it must remove both
# components' resources (and their install database entries).
COMPALL="$WORKDIR/component-all"
rm -rf "$COMPALL" /run/sysupdate.comp-a.d /run/sysupdate.comp-b.d \
/var/lib/systemd/sysupdate/installdb.comp-a /var/lib/systemd/sysupdate/installdb.comp-b
mkdir -p "$COMPALL/source" "$COMPALL/target-a" "$COMPALL/target-b" \
/run/sysupdate.comp-a.d /run/sysupdate.comp-b.d
echo "$RANDOM" >"$COMPALL/source/comp-a-v1.bin"
echo "$RANDOM" >"$COMPALL/source/comp-b-v1.bin"
(cd "$COMPALL/source" && sha256sum comp-* >SHA256SUMS)
cat >/run/sysupdate.comp-a.d/01-comp-a.transfer <<EOF
[Source]
Type=regular-file
Path=$COMPALL/source
MatchPattern=comp-a-@v.bin
[Target]
Type=regular-file
Path=$COMPALL/target-a
MatchPattern=comp-a-@v.bin
InstancesMax=1
EOF
cat >/run/sysupdate.comp-b.d/01-comp-b.transfer <<EOF
[Source]
Type=regular-file
Path=$COMPALL/source
MatchPattern=comp-b-@v.bin
[Target]
Type=regular-file
Path=$COMPALL/target-b
MatchPattern=comp-b-@v.bin
InstancesMax=1
EOF
"$SYSUPDATE" --component=comp-a --verify=no update
"$SYSUPDATE" --component=comp-b --verify=no update
test -f "$COMPALL/target-a/comp-a-v1.bin"
test -f "$COMPALL/target-b/comp-b-v1.bin"
test -d /var/lib/systemd/sysupdate/installdb.comp-a
test -d /var/lib/systemd/sysupdate/installdb.comp-b
# --component-all is not supported for every verb, so it must be refused where it isn't (e.g. "vacuum").
# (It *is* supported for update/acquire/cleanup/enable-*/disable-*, which is exercised further down.)
(! "$SYSUPDATE" --component-all --verify=no vacuum)
# With the transfer files still in place "cleanup --component-all" is a no-op:
# nothing is orphaned.
"$SYSUPDATE" --component-all cleanup
test -f "$COMPALL/target-a/comp-a-v1.bin"
test -f "$COMPALL/target-b/comp-b-v1.bin"
# Drop both transfer files and clean up all components at once. Both resources
# (and their install database entries) must now be gone.
rm /run/sysupdate.comp-a.d/01-comp-a.transfer /run/sysupdate.comp-b.d/01-comp-b.transfer
"$SYSUPDATE" --component-all cleanup
test ! -e "$COMPALL/target-a/comp-a-v1.bin"
test ! -e "$COMPALL/target-b/comp-b-v1.bin"
[[ "$(find /var/lib/systemd/sysupdate/installdb.comp-a -type l | wc -l)" -eq 0 ]]
[[ "$(find /var/lib/systemd/sysupdate/installdb.comp-b -type l | wc -l)" -eq 0 ]]
rm -rf "$COMPALL" /run/sysupdate.comp-a.d /run/sysupdate.comp-b.d \
/var/lib/systemd/sysupdate/installdb.comp-a /var/lib/systemd/sysupdate/installdb.comp-b
# Check the "--cleanup=" switch of the "update" verb. With "--cleanup=yes" a
# successful update must, after installing the new version, run the equivalent of
# the "cleanup" verb and remove any resources that are no longer owned by a
# currently defined transfer file. Reuse the "alpha"/"beta" helpers from above.
rm -rf "$CONFIGDIR" "$INSTALLDB" "$CLEANUP"
mkdir -p "$CONFIGDIR" "$CLEANUP/source" "$CLEANUP/target"
cat >"$CONFIGDIR/01-alpha.transfer" <<EOF
[Source]
Type=regular-file
Path=$CLEANUP/source
MatchPattern=alpha-@v.bin
[Target]
Type=regular-file
Path=$CLEANUP/target
MatchPattern=alpha-@v.bin
InstancesMax=2
EOF
cat >"$CONFIGDIR/02-beta.transfer" <<EOF
[Source]
Type=directory
Path=$CLEANUP/source
MatchPattern=beta-@v
[Target]
Type=directory
Path=$CLEANUP/target
MatchPattern=beta-@v
InstancesMax=2
EOF
# Install a first version with both transfers in place.
cleanup_new_version v1
"$SYSUPDATE" --verify=no update --cleanup=yes
test -f "$CLEANUP/target/alpha-v1.bin"
verify_beta_synced v1
[[ "$(installdb_count)" -eq 2 ]]
assert_installdb_covers_target
# Now drop the "beta" transfer file and install a second version with
# "--cleanup=yes". The new alpha resource must be installed, and the now-orphaned
# beta directory (and its install database entry) must be removed as part of the
# same invocation, without a separate "cleanup" call.
rm "$CONFIGDIR/02-beta.transfer"
cleanup_new_version v2
"$SYSUPDATE" --verify=no update --cleanup=yes
test -f "$CLEANUP/target/alpha-v1.bin"
test -f "$CLEANUP/target/alpha-v2.bin"
test ! -e "$CLEANUP/target/beta-v1"
[[ "$(installdb_count)" -eq 1 ]]
assert_installdb_covers_target
# With "--cleanup=no" (the default) orphaned resources must be left in place.
# Redefine the "alpha" transfer so its patterns no longer match the already
# installed alpha files (turning them into orphans), while keeping a valid
# transfer definition in place. Updating with "--cleanup=no" must then install
# nothing new (there's no matching source) and leave the now-orphaned alpha files
# and their install database entry untouched.
cat >"$CONFIGDIR/01-alpha.transfer" <<EOF
[Source]
Type=regular-file
Path=$CLEANUP/source
MatchPattern=gamma-@v.bin
[Target]
Type=regular-file
Path=$CLEANUP/target
MatchPattern=gamma-@v.bin
InstancesMax=2
EOF
"$SYSUPDATE" --verify=no update --cleanup=no
test -f "$CLEANUP/target/alpha-v1.bin"
test -f "$CLEANUP/target/alpha-v2.bin"
[[ "$(installdb_count)" -eq 1 ]]
# Invoking the "cleanup" verb with "--cleanup=no" is contradictory and must be
# refused.
(! "$SYSUPDATE" --cleanup=no cleanup) |& grep "contradictory" >/dev/null
# A plain "cleanup" must still remove the orphaned alpha files.
"$SYSUPDATE" cleanup
test ! -f "$CLEANUP/target/alpha-v1.bin"
test ! -f "$CLEANUP/target/alpha-v2.bin"
[[ "$(installdb_count)" -eq 0 ]]
rm -rf "$CONFIGDIR" "$INSTALLDB" "$CLEANUP"
# Verify the notification callout: after a successful update, sysupdate must connect to every socket in
# /run/systemd/sysupdate/notify/ and invoke io.systemd.SysUpdate.Notify.OnCompletedUpdate(). We hook a tiny
# recorder socket into that directory that captures the request and replies with success.
NOTIFY_LOG="$WORKDIR/notify.log"
rm -f "$NOTIFY_LOG"
cat >"$WORKDIR/notify-recorder.py" <<EOF
#!/usr/bin/env python3
# Minimal Varlink server: read one NUL-terminated request, record it, reply with empty parameters.
import sys
buf = b""
while True:
c = sys.stdin.buffer.read(1)
if not c or c == b"\x00":
break
buf += c
with open("$NOTIFY_LOG", "ab") as f:
f.write(buf + b"\n")
sys.stdout.buffer.write(b'{"parameters":{}}\x00')
sys.stdout.buffer.flush()
EOF
chmod +x "$WORKDIR/notify-recorder.py"
cat >/run/systemd/system/test-sysupdate-notify-recorder.socket <<EOF
[Socket]
ListenStream=/run/systemd/sysupdate/notify/io.test.SysUpdateRecorder
Accept=yes
EOF
cat >"/run/systemd/system/test-sysupdate-notify-recorder@.service" <<EOF
[Service]
ExecStart=$WORKDIR/notify-recorder.py
StandardInput=socket
StandardOutput=socket
EOF
systemctl daemon-reload
systemctl start test-sysupdate-notify-recorder.socket
rm -rf "$CONFIGDIR" "$WORKDIR/blobs"
mkdir -p "$CONFIGDIR" "$WORKDIR/blobs"
echo "hello" >"$WORKDIR/source/notifytest-v1.bin"
(cd "$WORKDIR/source" && sha256sum notifytest-v1.bin >SHA256SUMS)
cat >"$CONFIGDIR/01-notifytest.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/source
MatchPattern=notifytest-@v.bin
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=notifytest-@v.bin
InstancesMax=1
EOF
# A real update must trigger exactly one notification carrying the version and the updated resources.
# The callout is synchronous (sysupdate blocks until the subscriber replied, which happens after the
# request was recorded), so the log is fully written by the time the update returns.
"$SYSUPDATE" --verify=no update
test -s "$NOTIFY_LOG" # the notification must have been recorded
notify_line="$(tail -n1 "$NOTIFY_LOG")"
echo "Recorded notification: $notify_line"
jq -e '.method == "io.systemd.SysUpdate.Notify.OnCompletedUpdate"' <<<"$notify_line" >/dev/null
jq -e '.parameters.version == "v1"' <<<"$notify_line" >/dev/null
jq -e '.parameters.resources | length >= 1' <<<"$notify_line" >/dev/null
jq -e '.parameters.resources | all(has("transfer"))' <<<"$notify_line" >/dev/null
# A no-op update ("No update needed") must NOT emit a notification.
rm -f "$NOTIFY_LOG"
"$SYSUPDATE" --verify=no update
test ! -s "$NOTIFY_LOG"
systemctl stop test-sysupdate-notify-recorder.socket
rm -f /run/systemd/system/test-sysupdate-notify-recorder.socket \
/run/systemd/system/test-sysupdate-notify-recorder@.service
systemctl daemon-reload
rm -rf "$CONFIGDIR" "$WORKDIR/blobs"
rm -f "$WORKDIR/source/notifytest-v1.bin" "$WORKDIR/source/SHA256SUMS" \
"$WORKDIR/notify-recorder.py" "$NOTIFY_LOG"
test_signature_verification() {
if ! command -v gpg >/dev/null; then
echo "gpg not available, skipping signature verification test"
return 0
fi
# Checking for --auto-key-import is not enough because the merge-only/import-clean guarantee we rely on
# only works correctly with gpg 2.4
local gpg_version gpg_rest
gpg_version="$(gpg --version | sed -n '1p' | awk '{print $NF}')"
gpg_rest="${gpg_version#*.}"
if [ "${gpg_version%%.*}" -lt 2 ] || { [ "${gpg_version%%.*}" -eq 2 ] && [ "${gpg_rest%%.*}" -lt 4 ]; }; then
echo "gpg $gpg_version too old (need >= 2.4), skipping signature verification test"
return 0
fi
local sigdir="$WORKDIR/sigtest-source"
local defdir="$WORKDIR/sigtest-defs"
local gpghome="$WORKDIR/sigtest-gpghome"
local other_home="$WORKDIR/sigtest-otherhome"
local target="$WORKDIR/sigtest-target"
local keyring="$WORKDIR/sigtest-keyring"
local top_fpr keys
SIGTEST_GPGHOME="$gpghome"
SIGTEST_OTHERHOME="$other_home"
mkdir -p "$sigdir" "$defdir" "$gpghome" "$other_home" "$target"
chmod 700 "$gpghome" "$other_home"
GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \
--quick-gen-key 'Test Key <test@example.com>' rsa2048 cert,sign never
# Capture gpg output first, then grep from a here-string to avoid grep -m1 causing a SIGPIPE
keys="$(GNUPGHOME="$gpghome" gpg --list-keys --with-colons)"
top_fpr="$(grep -m1 '^fpr:' <<< "$keys" | cut -d: -f10)"
test "$top_fpr" != ""
GNUPGHOME="$gpghome" gpg --export --output "$keyring"
dd if=/dev/urandom of="$sigdir/payload-v1.raw" bs=1024 count=8 status=none
(cd "$sigdir" && sha256sum payload-v1.raw > SHA256SUMS)
GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \
--detach-sign --include-key-block --yes \
--output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS"
cat >"$defdir/01-sigtest.transfer" <<EOF
[Source]
Type=url-file
Path=file://$sigdir
MatchPattern=payload-@v.raw
[Target]
Type=regular-file
Path=$target
MatchPattern=payload-@v.raw
InstancesMax=3
EOF
SYSTEMD_OPENPGP_KEYRING="$keyring" "$SYSUPDATE" --definitions="$defdir" check-new
SYSTEMD_OPENPGP_KEYRING="$keyring" "$SYSUPDATE" --definitions="$defdir" update
cmp "$sigdir/payload-v1.raw" "$target/payload-v1.raw"
# Negative test: Sign with a key not in the keyring
GNUPGHOME="$other_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--quick-gen-key 'Other Key <other@example.com>' rsa2048 cert,sign never
dd if=/dev/urandom of="$sigdir/payload-v2.raw" bs=1024 count=8 status=none
(cd "$sigdir" && sha256sum payload-v1.raw payload-v2.raw > SHA256SUMS)
GNUPGHOME="$other_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--detach-sign --include-key-block --yes \
--output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS"
if SYSTEMD_OPENPGP_KEYRING="$keyring" "$SYSUPDATE" --definitions="$defdir" update; then
echo "ERROR: accepted an update signed by a key not in the keyring" >&2
exit 1
fi
if [ -f "$target/payload-v2.raw" ]; then
echo "ERROR: payload-v2 should not have been installed" >&2
exit 1
fi
# Sub key test: Add a sub key the client does not have and rely on gpg
# --auto-key-import to get it from the signature.
GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \
--quick-add-key "$top_fpr" rsa2048 sign 1y
# Make it so that only the sub key is available for signing to avoid having
# to select it by fingerprint.
GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \
--output "$WORKDIR/sigtest-subkey-secret.gpg" \
--export-secret-subkeys
GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \
--yes --delete-secret-keys "$top_fpr"
GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \
--import "$WORKDIR/sigtest-subkey-secret.gpg"
GNUPGHOME="$gpghome" gpg --batch --pinentry-mode loopback --passphrase '' \
--detach-sign --include-key-block --yes \
--output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS"
SYSTEMD_OPENPGP_KEYRING="$keyring" "$SYSUPDATE" --definitions="$defdir" update
cmp "$sigdir/payload-v2.raw" "$target/payload-v2.raw"
}
backup_import_keyring() {
local path="$1"
local backup="$2"
rm -f "$backup"
if [ -e "$path" ] || [ -L "$path" ]; then
mv "$path" "$backup"
fi
}
restore_import_keyring() {
local path="$1"
local backup="$2"
rm -f "$path"
if [ -e "$backup" ] || [ -L "$backup" ]; then
mv "$backup" "$path"
fi
}
test_signature_keyring_overlay() (
if ! command -v gpg >/dev/null; then
echo "gpg not available, skipping signature overlay test"
exit 0
fi
local gpg_version gpg_rest
gpg_version="$(gpg --version | sed -n '1p' | awk '{print $NF}')"
gpg_rest="${gpg_version#*.}"
if [ "${gpg_version%%.*}" -lt 2 ] || { [ "${gpg_version%%.*}" -eq 2 ] && [ "${gpg_rest%%.*}" -lt 4 ]; }; then
echo "gpg $gpg_version too old (need >= 2.4), skipping signature overlay test"
exit 0
fi
local sigdir="$WORKDIR/sigtest-overlay-source"
local defdir="$WORKDIR/sigtest-overlay-defs"
local user_home="$WORKDIR/sigtest-overlay-userhome"
local vendor_home="$WORKDIR/sigtest-overlay-vendorhome"
local other_home="$WORKDIR/sigtest-overlay-otherhome"
local target="$WORKDIR/sigtest-overlay-target"
local user_keyring="$WORKDIR/sigtest-overlay-user.gpg"
local vendor_keyring="$WORKDIR/sigtest-overlay-vendor.gpg"
local other_keyring="$WORKDIR/sigtest-overlay-other.gpg"
local etc_pgp_backup="$WORKDIR/import-pubring.etc.pgp.bak"
local etc_gpg_backup="$WORKDIR/import-pubring.etc.gpg.bak"
local usr_pgp_backup="$WORKDIR/import-pubring.usr.pgp.bak"
# Called via trap on EXIT; shellcheck cannot see the indirect invocation.
# shellcheck disable=SC2329,SC2317
cleanup_overlay_keyrings() {
set +e
gpgconf --homedir "$user_home" --kill all 2>/dev/null || :
gpgconf --homedir "$vendor_home" --kill all 2>/dev/null || :
gpgconf --homedir "$other_home" --kill all 2>/dev/null || :
restore_import_keyring /etc/systemd/import-pubring.pgp "$etc_pgp_backup"
restore_import_keyring /etc/systemd/import-pubring.gpg "$etc_gpg_backup"
restore_import_keyring /usr/lib/systemd/import-pubring.pgp "$usr_pgp_backup"
}
mkdir -p "$sigdir" "$defdir" "$user_home" "$vendor_home" "$other_home" "$target" /etc/systemd /usr/lib/systemd
chmod 700 "$user_home" "$vendor_home" "$other_home"
trap cleanup_overlay_keyrings EXIT
backup_import_keyring /etc/systemd/import-pubring.pgp "$etc_pgp_backup"
backup_import_keyring /etc/systemd/import-pubring.gpg "$etc_gpg_backup"
backup_import_keyring /usr/lib/systemd/import-pubring.pgp "$usr_pgp_backup"
GNUPGHOME="$user_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--quick-gen-key 'Overlay User <overlay-user@example.com>' rsa2048 cert,sign never
GNUPGHOME="$user_home" gpg --export --output "$user_keyring"
GNUPGHOME="$vendor_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--quick-gen-key 'Overlay Vendor <overlay-vendor@example.com>' rsa2048 cert,sign never
GNUPGHOME="$vendor_home" gpg --export --output "$vendor_keyring"
GNUPGHOME="$other_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--quick-gen-key 'Overlay Other <overlay-other@example.com>' rsa2048 cert,sign never
GNUPGHOME="$other_home" gpg --export --output "$other_keyring"
install -Dm0644 "$user_keyring" /etc/systemd/import-pubring.gpg
install -Dm0644 "$vendor_keyring" /usr/lib/systemd/import-pubring.pgp
rm -f /etc/systemd/import-pubring.pgp
dd if=/dev/urandom of="$sigdir/payload-v1.raw" bs=1024 count=8 status=none
(cd "$sigdir" && sha256sum payload-v1.raw > SHA256SUMS)
GNUPGHOME="$vendor_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--detach-sign --include-key-block --yes \
--output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS"
cat >"$defdir/01-sigtest.transfer" <<EOF
[Source]
Type=url-file
Path=file://$sigdir
MatchPattern=payload-@v.raw
[Target]
Type=regular-file
Path=$target
MatchPattern=payload-@v.raw
InstancesMax=3
EOF
"$SYSUPDATE" --definitions="$defdir" check-new
"$SYSUPDATE" --definitions="$defdir" update
cmp "$sigdir/payload-v1.raw" "$target/payload-v1.raw"
dd if=/dev/urandom of="$sigdir/payload-v2.raw" bs=1024 count=8 status=none
(cd "$sigdir" && sha256sum payload-v1.raw payload-v2.raw > SHA256SUMS)
GNUPGHOME="$other_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--detach-sign --include-key-block --yes \
--output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS"
if "$SYSUPDATE" --definitions="$defdir" update; then
echo "ERROR: accepted an update signed by a key not in the default keyrings" >&2
exit 1
fi
if [ -f "$target/payload-v2.raw" ]; then
echo "ERROR: payload-v2 should not have been installed" >&2
exit 1
fi
dd if=/dev/urandom of="$sigdir/payload-v3.raw" bs=1024 count=8 status=none
(cd "$sigdir" && sha256sum payload-v1.raw payload-v2.raw payload-v3.raw > SHA256SUMS)
GNUPGHOME="$vendor_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--detach-sign --include-key-block --yes \
--output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS"
if SYSTEMD_OPENPGP_KEYRING="$other_keyring" "$SYSUPDATE" --definitions="$defdir" update; then
echo "ERROR: accepted an update with an override keyring that lacks the signing key" >&2
exit 1
fi
if [ -f "$target/payload-v3.raw" ]; then
echo "ERROR: payload-v3 should not have been installed" >&2
exit 1
fi
install -Dm0644 "$user_keyring" /etc/systemd/import-pubring.pgp
rm -f /etc/systemd/import-pubring.gpg
dd if=/dev/urandom of="$sigdir/payload-v4.raw" bs=1024 count=8 status=none
(cd "$sigdir" && sha256sum payload-v1.raw payload-v2.raw payload-v3.raw payload-v4.raw > SHA256SUMS)
GNUPGHOME="$user_home" gpg --batch --pinentry-mode loopback --passphrase '' \
--detach-sign --include-key-block --yes \
--output "$sigdir/SHA256SUMS.gpg" "$sigdir/SHA256SUMS"
"$SYSUPDATE" --definitions="$defdir" update
cmp "$sigdir/payload-v4.raw" "$target/payload-v4.raw"
)
test_signature_verification
test_signature_keyring_overlay
# Test '**/' as prefix in MatchPattern= for subpaths in SHA256SUMS
rm -rf "$CONFIGDIR" "$WORKDIR/blobs" "$WORKDIR/source/sub"
mkdir -p "$CONFIGDIR" "$WORKDIR/blobs" "$WORKDIR/source/sub"
printf '1234567' >"$WORKDIR/source/sub/blob-v10.bin"
printf 'abcdefg' >"$WORKDIR/source/sub/blob-v11.bin"
(cd "$WORKDIR/source" && rm -f BEST-BEFORE-* && sha256sum sub/blob-*.bin >SHA256SUMS)
# A regular-file source where '**/' descends into sub/ to match against the basename
cat >"$CONFIGDIR/01-basename-dir.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=**/blob-@v.bin
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=blob-@v.bin
InstancesMax=1
EOF
"$SYSUPDATE" --verify=no update
cmp "$WORKDIR/source/sub/blob-v11.bin" "$WORKDIR/blobs/blob-v11.bin"
[[ "$("$SYSUPDATE" --verify=no list v11 | grep -c "Version: v11")" == "1" ]]
rm "$CONFIGDIR/01-basename-dir.transfer"
# A url-file source pulled via file:// using SHA256SUMS with "sub/blob-v1x.bin" entries
rm -rf "$WORKDIR/blobs"
mkdir -p "$WORKDIR/blobs"
cat >"$CONFIGDIR/01-basename-url.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/source
MatchPattern=**/blob-@v.bin
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=blob-@v.bin
InstancesMax=1
EOF
"$SYSUPDATE" --verify=no update
cmp "$WORKDIR/source/sub/blob-v11.bin" "$WORKDIR/blobs/blob-v11.bin"
rm "$CONFIGDIR/01-basename-url.transfer"
# A pattern that spells out the subdir literally should also work for url sources
# for parity with regular-file sources
rm -rf "$WORKDIR/blobs"
mkdir -p "$WORKDIR/blobs"
cat >"$CONFIGDIR/01-explicit-url.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/source
MatchPattern=sub/blob-@v.bin
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=blob-@v.bin
InstancesMax=1
EOF
"$SYSUPDATE" --verify=no update
cmp "$WORKDIR/source/sub/blob-v11.bin" "$WORKDIR/blobs/blob-v11.bin"
rm "$CONFIGDIR/01-explicit-url.transfer"
# Rejection test for a manifest entry containing ".."
rm -rf "$WORKDIR/blobs"
mkdir -p "$WORKDIR/blobs"
mkdir -p "$WORKDIR/source/sub/nested"
cp "$WORKDIR/source/SHA256SUMS" "$WORKDIR/source/SHA256SUMS.bak"
sed -i 's,sub/,sub/nested/../,g' "$WORKDIR/source/SHA256SUMS"
cat >"$CONFIGDIR/01-basename-url.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/source
MatchPattern=**/blob-@v.bin
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=blob-@v.bin
EOF
if "$SYSUPDATE" --verify=no update |& tee "$WORKDIR/traversal.log"; then
echo "ERROR: accepted a manifest entry with a '..' path traversal" >&2
exit 1
fi
grep "Invalid filename" "$WORKDIR/traversal.log" >/dev/null
mv "$WORKDIR/source/SHA256SUMS.bak" "$WORKDIR/source/SHA256SUMS"
rm "$CONFIGDIR/01-basename-url.transfer"
# Rejection test for a manifest entry with a percent-encoded ".." which curl would
# decode when pulling via file://, escaping the source dir past path_is_normalized()
rm -rf "$WORKDIR/blobs"
mkdir -p "$WORKDIR/blobs"
cp "$WORKDIR/source/SHA256SUMS" "$WORKDIR/source/SHA256SUMS.bak"
sed -i 's,sub/,sub/nested/%2e%2e/,g' "$WORKDIR/source/SHA256SUMS"
cat >"$CONFIGDIR/01-basename-url.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/source
MatchPattern=**/blob-@v.bin
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=blob-@v.bin
EOF
if "$SYSUPDATE" --verify=no update |& tee "$WORKDIR/percent.log"; then
echo "ERROR: accepted a manifest entry with a percent-encoded '..' path traversal" >&2
exit 1
fi
grep "Invalid filename" "$WORKDIR/percent.log" >/dev/null
mv "$WORKDIR/source/SHA256SUMS.bak" "$WORKDIR/source/SHA256SUMS"
rm "$CONFIGDIR/01-basename-url.transfer"
# A MatchPattern= with two subdirectory levels must descend beyond the first level
rm -rf "$CONFIGDIR" "$WORKDIR/blobs" "$WORKDIR/source/depth-v3"
mkdir -p "$CONFIGDIR" "$WORKDIR/blobs" "$WORKDIR/source/depth-v3/contents"
printf 'version-three' >"$WORKDIR/source/depth-v3/contents/image.raw"
cat >"$CONFIGDIR/01-depth.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=depth-@v/contents/image.raw
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=depth-@v.raw
InstancesMax=1
EOF
"$SYSUPDATE" --verify=no update
cmp "$WORKDIR/source/depth-v3/contents/image.raw" "$WORKDIR/blobs/depth-v3.raw"
rm -rf "$CONFIGDIR/01-depth.transfer" "$WORKDIR/source/depth-v3"
# Pattern matching should not depend on the pattern order. A transfer can list one pattern per repository
# layout, e.g., when a source switches from one directory per version (bundle-v1/image.raw) to flat files
# (bundle-v2.raw) and both layouts coexist during a migration.
# Check that after a subdirectory pattern which descends we still can match a top-level file through a
# '**/' pattern.
# Also check that after a non-matching '**/' pattern we still can match a plain top-level file.
# Then check that after a subdirectory pattern we can still match a plain top-level file.
# The first and last work because the wildcard field also captures the '.raw' suffix, so the top-level file
# matches the directory component of the subdirectory pattern and triggers the descend-retry logic.
# The fourth transfer checks the vice versa migration where one directory per version is the new layout and
# the old versions are flat files without a suffix: the directory name fully matches the flat pattern but
# must still be descended into for the subdirectory pattern.
rm -rf "$CONFIGDIR" "$WORKDIR/blobs" "$WORKDIR/source/bundle-v1" "$WORKDIR/source/pack-v2"
mkdir -p "$CONFIGDIR" "$WORKDIR/blobs"/{glob-last,glob-first,subdir-first,yes-and-retry} \
"$WORKDIR/source/bundle-v1" "$WORKDIR/source/pack-v2"
echo 'version-one' >"$WORKDIR/source/bundle-v1/image.raw"
echo 'version-two' >"$WORKDIR/source/bundle-v2.raw"
echo 'version-v1' >"$WORKDIR/source/pack-v1"
echo 'version-v2' >"$WORKDIR/source/pack-v2/image.raw"
cat >"$CONFIGDIR/01-glob-last.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=bundle-@v/image.raw **/bundle-@v.raw
[Target]
Type=regular-file
Path=$WORKDIR/blobs/glob-last
MatchPattern=bundle-@v.raw
InstancesMax=1
EOF
cat >"$CONFIGDIR/02-glob-first.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=**/bundle-@v.img bundle-@v.raw
[Target]
Type=regular-file
Path=$WORKDIR/blobs/glob-first
MatchPattern=bundle-@v.raw
InstancesMax=1
EOF
cat >"$CONFIGDIR/03-subdir-first.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=bundle-@v/image.raw bundle-@v.raw
[Target]
Type=regular-file
Path=$WORKDIR/blobs/subdir-first
MatchPattern=bundle-@v.raw
InstancesMax=1
EOF
cat >"$CONFIGDIR/04-yes-and-retry.transfer" <<EOF
[Source]
Type=regular-file
Path=$WORKDIR/source
MatchPattern=pack-@v/image.raw pack-@v
[Target]
Type=regular-file
Path=$WORKDIR/blobs/yes-and-retry
MatchPattern=pack-@v
InstancesMax=1
EOF
"$SYSUPDATE" --verify=no update
for target in glob-last glob-first subdir-first; do
cmp "$WORKDIR/source/bundle-v2.raw" "$WORKDIR/blobs/$target/bundle-v2.raw"
done
cmp "$WORKDIR/source/pack-v2/image.raw" "$WORKDIR/blobs/yes-and-retry/pack-v2"
rm -rf "$CONFIGDIR"/{01-glob-last,02-glob-first,03-subdir-first,04-yes-and-retry}.transfer \
"$WORKDIR/source/bundle-v2.raw" "$WORKDIR/source/bundle-v1" \
"$WORKDIR/source/pack-v1" "$WORKDIR/source/pack-v2"
# A manifest entry that both directly matches a flat pattern and prefix-matches a subdirectory pattern
# must be downloaded, here the flat file is the newest version (this exercises YES_AND_RETRY)
rm -rf "$CONFIGDIR" "$WORKDIR/blobs" "$WORKDIR/source/pack-v1"
mkdir -p "$CONFIGDIR" "$WORKDIR/blobs" "$WORKDIR/source/pack-v1"
echo 'version-v1' >"$WORKDIR/source/pack-v1/image.raw"
echo 'version-v2' >"$WORKDIR/source/pack-v2"
(cd "$WORKDIR/source" && sha256sum pack-v1/image.raw pack-v2 >SHA256SUMS)
cat >"$CONFIGDIR/01-manifest-yes-and-retry.transfer" <<EOF
[Source]
Type=url-file
Path=file://$WORKDIR/source
MatchPattern=pack-@v/image.raw pack-@v
[Target]
Type=regular-file
Path=$WORKDIR/blobs
MatchPattern=pack-@v
InstancesMax=1
EOF
"$SYSUPDATE" --verify=no update
cmp "$WORKDIR/source/pack-v2" "$WORKDIR/blobs/pack-v2"
rm -rf "$CONFIGDIR/01-manifest-yes-and-retry.transfer" \
"$WORKDIR/source/pack-v1" "$WORKDIR/source/pack-v2"
# Test that listing components/targets when none are configured gives an empty list.
"$SYSUPDATE" components |& grep "No components defined." >/dev/null
[[ $(varlinkctl call "$VARLINK_SOCKET" io.systemd.SysUpdate.ListTargets | jq -r '.targets') == "[]" ]]
# ============================================================================
# Components & features: enable/disable verbs, the --component-{all,suggested}
# and --feature-{all,suggested} switches, plus the Suggest=/SuggestOn…=
# settings. Everything below operates on freshly minted throw-away components
# and features, and cleans up after itself.
#
# Layout used throughout:
# * a component <c> is defined by a transfer directory /run/sysupdate.<c>.d/
# (which is what makes it show up in the 'components' list) plus an optional
# metadata file /run/sysupdate.<c>.component carrying Description=/Suggest=/…
# * the enable-component/disable-component verbs write their Enabled= override
# into a drop-in below /etc/sysupdate.<c>.component.d/
# * the enable-feature/disable-feature verbs write their Enabled= override into
# a drop-in below /etc/sysupdate.d/<f>.feature.d/ (default component) or
# /etc/sysupdate.<c>.d/<f>.feature.d/ (named component)
# ============================================================================
CF="$WORKDIR/compfeat"
# The Suggest…MachineTag= tests below drive the machine tags via /etc/machine-info
# (which the condition logic reads directly); back up any pre-existing file so we
# can restore it afterwards, mirroring TEST-74-AUX-UTILS.machine-tags.sh.
MI_BAK="$WORKDIR/machine-info.orig"
rm -f "$MI_BAK"
[[ -e /etc/machine-info ]] && cp -a /etc/machine-info "$MI_BAK"
set_machine_tags() {
if [[ -n "${1:-}" ]]; then
echo "TAGS=$1" >/etc/machine-info
else
rm -f /etc/machine-info
fi
}
restore_machine_info() {
if [[ -e "$MI_BAK" ]]; then
cp -a "$MI_BAK" /etc/machine-info
else
rm -f /etc/machine-info
fi
}
compfeat_cleanup() {
rm -rf /run/sysupdate.d \
/run/sysupdate.compx.d /run/sysupdate.compx.component /run/sysupdate.compx.component.d \
/run/sysupdate.compy.d /run/sysupdate.compy.component /run/sysupdate.compy.component.d \
/run/sysupdate.compz.d /run/sysupdate.compz.component /run/sysupdate.compz.component.d \
/etc/sysupdate.d \
/etc/sysupdate.compx.d /etc/sysupdate.compx.component.d \
/etc/sysupdate.compy.d /etc/sysupdate.compy.component.d \
/etc/sysupdate.compz.d /etc/sysupdate.compz.component.d
rm -rf "$CF"
}
compfeat_reset() {
compfeat_cleanup
mkdir -p "$CF/source" \
"$CF/target-default" "$CF/target-compx" "$CF/target-compy" "$CF/target-compz"
}
# (Re)generate the source payloads + SHA256SUMS for a given version. We create a
# payload for every component/feature so a single SHA256SUMS covers them all;
# individual transfers only ever match their own pattern.
compfeat_source() {
local v="${1:?}"
local n
for n in base compx compy compz feata featb featc; do
echo "$n-$v-$RANDOM" >"$CF/source/$n-$v.bin"
done
(cd "$CF/source" && sha256sum -- *.bin >SHA256SUMS)
}
# Write a regular-file transfer; optional 4th argument gates it behind a feature.
compfeat_transfer() {
local file="${1:?}" pat="${2:?}" tgt="${3:?}" feature="${4:-}"
{
if [[ -n "$feature" ]]; then
printf '[Transfer]\nFeatures=%s\n\n' "$feature"
fi
printf '[Source]\nType=regular-file\nPath=%s\nMatchPattern=%s-@v.bin\n\n' "$CF/source" "$pat"
printf '[Target]\nType=regular-file\nPath=%s\nMatchPattern=%s-@v.bin\nInstancesMax=2\n' "$tgt" "$pat"
} >"$file"
}
comp_enable_dropin() { echo "/etc/sysupdate.$1.component.d/50-systemd-sysupdate-enabled.conf"; }
feat_enable_dropin_default() { echo "/etc/sysupdate.d/$1.feature.d/50-systemd-sysupdate-enabled.conf"; }
feat_enable_dropin_comp() { echo "/etc/sysupdate.$1.d/$2.feature.d/50-systemd-sysupdate-enabled.conf"; }
# Assert a generated Enabled= drop-in exists and carries the expected value.
assert_dropin() {
local file="${1:?}" val="${2:?}"
test -f "$file"
grep "^Enabled=$val$" "$file" >/dev/null
}
# ---------------------------------------------------------------------------
# enable-component / disable-component: explicit selection + observable effect
# ---------------------------------------------------------------------------
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.compx.d
compfeat_transfer /run/sysupdate.compx.d/01-compx.transfer compx "$CF/target-compx"
# A metadata file carrying a human-readable description (exercises loading of the
# per-component *.component file from the search dirs).
cat >/run/sysupdate.compx.component <<EOF
[Component]
Description=CompXDescription
EOF
# The description from the *.component file must surface in the 'components' listing.
"$SYSUPDATE" --no-legend components | grep -F "CompXDescription" >/dev/null
# A brand new component (no Enabled= override anywhere) is enabled by default and
# can be updated.
"$SYSUPDATE" --component=compx --verify=no update
test -f "$CF/target-compx/compx-v1.bin"
# Disable it: this must drop an Enabled=no override next to the definition, and
# subsequent updates for that component must be refused.
"$SYSUPDATE" disable-component compx
assert_dropin "$(comp_enable_dropin compx)" no
(! "$SYSUPDATE" --component=compx --verify=no update) |& grep -F "Component is disabled" >/dev/null
# Re-enable via the "--component= + no positional argument" form and verify the
# update works again.
"$SYSUPDATE" --component=compx enable-component
assert_dropin "$(comp_enable_dropin compx)" yes
rm -f "$CF/target-compx/compx-v1.bin"
"$SYSUPDATE" --component=compx --verify=no update
test -f "$CF/target-compx/compx-v1.bin"
# Enabling a component must not conjure a bogus "<c>.component" pseudo-component
# out of the freshly created sysupdate.compx.component.d/ drop-in directory.
(! "$SYSUPDATE" --json=short components | grep -F '"compx.component"' >/dev/null)
"$SYSUPDATE" --json=short components | grep -F '"compx"' >/dev/null
# ---------------------------------------------------------------------------
# enable-component / disable-component: argument validation & error handling
# ---------------------------------------------------------------------------
# Positional argument and --component= are mutually exclusive.
(! "$SYSUPDATE" --component=compx enable-component compx) |& grep -F "not both" >/dev/null
# Syntactically invalid component name.
(! "$SYSUPDATE" enable-component ../nope) |& grep -F "Component name invalid" >/dev/null
# Unknown component.
(! "$SYSUPDATE" enable-component doesnotexist) |& grep -F "Component not found" >/dev/null
# --definitions= is incompatible with component enablement.
(! "$SYSUPDATE" --definitions="$CF" enable-component compx) |& grep -F "may not be combined" >/dev/null
# The feature-selection switches make no sense here.
(! "$SYSUPDATE" --feature-all enable-component compx) |& grep -F "not supported" >/dev/null
# ---------------------------------------------------------------------------
# --component-all for enable-component / disable-component
# ---------------------------------------------------------------------------
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.compx.d /run/sysupdate.compy.d
compfeat_transfer /run/sysupdate.compx.d/01-compx.transfer compx "$CF/target-compx"
compfeat_transfer /run/sysupdate.compy.d/01-compy.transfer compy "$CF/target-compy"
# Disable *all* components in one go, then re-enable them all.
"$SYSUPDATE" --component-all disable-component
assert_dropin "$(comp_enable_dropin compx)" no
assert_dropin "$(comp_enable_dropin compy)" no
(! "$SYSUPDATE" --component=compx --verify=no update) |& grep -F "Component is disabled" >/dev/null
(! "$SYSUPDATE" --component=compy --verify=no update) |& grep -F "Component is disabled" >/dev/null
"$SYSUPDATE" --component-all enable-component
assert_dropin "$(comp_enable_dropin compx)" yes
assert_dropin "$(comp_enable_dropin compy)" yes
"$SYSUPDATE" --component=compx --verify=no update
"$SYSUPDATE" --component=compy --verify=no update
test -f "$CF/target-compx/compx-v1.bin"
test -f "$CF/target-compy/compy-v1.bin"
# ---------------------------------------------------------------------------
# --component-suggested driven by Suggest= (compx suggested, compy not)
# ---------------------------------------------------------------------------
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.compx.d /run/sysupdate.compy.d
compfeat_transfer /run/sysupdate.compx.d/01-compx.transfer compx "$CF/target-compx"
compfeat_transfer /run/sysupdate.compy.d/01-compy.transfer compy "$CF/target-compy"
cat >/run/sysupdate.compx.component <<EOF
[Component]
Suggest=yes
EOF
cat >/run/sysupdate.compy.component <<EOF
[Component]
Suggest=no
EOF
# 'enable-component --component-suggested' acts on the suggested components only.
"$SYSUPDATE" --component-suggested enable-component
assert_dropin "$(comp_enable_dropin compx)" yes
test ! -e "$(comp_enable_dropin compy)"
# 'disable-component --component-suggested' acts on the very same set, i.e. it
# undoes what the enable above did. The non-suggested compy is left untouched.
"$SYSUPDATE" --component-suggested disable-component
assert_dropin "$(comp_enable_dropin compx)" no
test ! -e "$(comp_enable_dropin compy)"
# Reconciling the system with the current suggestions is a two step operation:
# disable all components, then enable the suggested ones. Unlike the single
# 'disable-component --component-suggested' above this makes the non-suggested
# compy explicitly disabled.
"$SYSUPDATE" --component-all disable-component
"$SYSUPDATE" --component-suggested enable-component
assert_dropin "$(comp_enable_dropin compx)" yes
assert_dropin "$(comp_enable_dropin compy)" no
# --component-suggested is not supported for the update verb.
(! "$SYSUPDATE" --component-suggested --verify=no update) |& grep -F "not supported" >/dev/null
# ---------------------------------------------------------------------------
# SuggestOnMachineTag= for a component
# ---------------------------------------------------------------------------
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.compz.d
compfeat_transfer /run/sysupdate.compz.d/01-compz.transfer compz "$CF/target-compz"
cat >/run/sysupdate.compz.component <<EOF
[Component]
SuggestOnMachineTag=sysupdate-test-tag
EOF
# Without the matching machine tag the component is not suggested, so
# --component-suggested selects nothing.
set_machine_tags some-other-tag
"$SYSUPDATE" --component-suggested enable-component
test ! -e "$(comp_enable_dropin compz)"
# With the matching tag present it becomes suggested and gets enabled.
set_machine_tags sysupdate-test-tag:another
"$SYSUPDATE" --component-suggested enable-component
assert_dropin "$(comp_enable_dropin compz)" yes
set_machine_tags ""
# ---------------------------------------------------------------------------
# enable-feature / disable-feature on the default component + observable effect
# ---------------------------------------------------------------------------
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.d
compfeat_transfer /run/sysupdate.d/01-base.transfer base "$CF/target-default"
compfeat_transfer /run/sysupdate.d/50-feata.transfer feata "$CF/target-default" feata
cat >/run/sysupdate.d/feata.feature <<EOF
[Feature]
Description=Feature A
EOF
# The feature is listed and disabled by default, so its transfer is not installed.
"$SYSUPDATE" features | grep -F "feata" >/dev/null
"$SYSUPDATE" --verify=no update
test -f "$CF/target-default/base-v1.bin"
test ! -e "$CF/target-default/feata-v1.bin"
# Enabling the feature must drop an Enabled=yes override and cause the gated
# transfer to be installed on the next update.
"$SYSUPDATE" enable-feature feata
assert_dropin "$(feat_enable_dropin_default feata)" yes
"$SYSUPDATE" --verify=no update
test -f "$CF/target-default/feata-v1.bin"
# Disabling it again + vacuum must remove the now-orphaned feature resource.
"$SYSUPDATE" disable-feature feata
assert_dropin "$(feat_enable_dropin_default feata)" no
"$SYSUPDATE" --verify=no vacuum
test ! -e "$CF/target-default/feata-v1.bin"
# Argument validation for the feature verbs.
(! "$SYSUPDATE" enable-feature --feature-all feata) |& grep -F "not both" >/dev/null
(! "$SYSUPDATE" enable-feature 'bad/name') |& grep -F "Feature name invalid" >/dev/null
(! "$SYSUPDATE" --component-suggested enable-feature feata) |& grep -F "not supported" >/dev/null
# ---------------------------------------------------------------------------
# --feature-all / --feature-suggested (default component)
# ---------------------------------------------------------------------------
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.d
compfeat_transfer /run/sysupdate.d/01-base.transfer base "$CF/target-default"
# feata: suggested (Suggest=yes); featb: not suggested (Suggest=no);
# featc: suggested on a machine tag we will set below.
cat >/run/sysupdate.d/feata.feature <<EOF
[Feature]
Suggest=yes
EOF
cat >/run/sysupdate.d/featb.feature <<EOF
[Feature]
Suggest=no
EOF
cat >/run/sysupdate.d/featc.feature <<EOF
[Feature]
SuggestOnMachineTag=sysupdate-test-tag
EOF
# --feature-all operates on every known feature.
"$SYSUPDATE" enable-feature --feature-all
assert_dropin "$(feat_enable_dropin_default feata)" yes
assert_dropin "$(feat_enable_dropin_default featb)" yes
assert_dropin "$(feat_enable_dropin_default featc)" yes
# ... and so does 'disable-feature --feature-all', in the other direction.
"$SYSUPDATE" disable-feature --feature-all
assert_dropin "$(feat_enable_dropin_default feata)" no
assert_dropin "$(feat_enable_dropin_default featb)" no
assert_dropin "$(feat_enable_dropin_default featc)" no
# --feature-suggested (no machine tag): only the Suggest=yes feature is picked.
rm -rf /etc/sysupdate.d
set_machine_tags unrelated
"$SYSUPDATE" enable-feature --feature-suggested
assert_dropin "$(feat_enable_dropin_default feata)" yes
test ! -e "$(feat_enable_dropin_default featb)"
test ! -e "$(feat_enable_dropin_default featc)"
# --feature-suggested with the machine tag set: feata + featc are picked.
rm -rf /etc/sysupdate.d
set_machine_tags sysupdate-test-tag
"$SYSUPDATE" enable-feature --feature-suggested
assert_dropin "$(feat_enable_dropin_default feata)" yes
assert_dropin "$(feat_enable_dropin_default featc)" yes
test ! -e "$(feat_enable_dropin_default featb)"
# 'disable-feature --feature-suggested' selects the same features as the enable
# above, i.e. it undoes it and leaves the non-suggested featb untouched.
"$SYSUPDATE" disable-feature --feature-suggested
assert_dropin "$(feat_enable_dropin_default feata)" no
assert_dropin "$(feat_enable_dropin_default featc)" no
test ! -e "$(feat_enable_dropin_default featb)"
# The same two step reconciliation for features: disable all of them, then
# enable the suggested ones, leaving the non-suggested featb explicitly off.
"$SYSUPDATE" disable-feature --feature-all
"$SYSUPDATE" enable-feature --feature-suggested
assert_dropin "$(feat_enable_dropin_default feata)" yes
assert_dropin "$(feat_enable_dropin_default featc)" yes
assert_dropin "$(feat_enable_dropin_default featb)" no
set_machine_tags ""
# --component-all must still include the default component when all of its
# transfers are currently disabled by features.
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.d
compfeat_transfer /run/sysupdate.d/50-feata.transfer feata "$CF/target-default" feata
cat >/run/sysupdate.d/feata.feature <<EOF
[Feature]
Description=Feature A
EOF
"$SYSUPDATE" --component-all enable-feature --feature-all
assert_dropin "$(feat_enable_dropin_default feata)" yes
# --component-all must include the default component under --root= too.
ROOT_FEATURE="$WORKDIR/root-feature"
ROOT_FEATURE_ROOT="$ROOT_FEATURE/root"
rm -rf "$ROOT_FEATURE"
mkdir -p "$ROOT_FEATURE_ROOT/etc/sysupdate.d" \
"$ROOT_FEATURE_ROOT/source" \
"$ROOT_FEATURE_ROOT/target"
cat >"$ROOT_FEATURE_ROOT/etc/sysupdate.d/50-feata.transfer" <<EOF
[Transfer]
Features=feata
[Source]
Type=regular-file
Path=/source
MatchPattern=feata-@v.bin
[Target]
Type=regular-file
Path=/target
MatchPattern=feata-@v.bin
InstancesMax=2
EOF
cat >"$ROOT_FEATURE_ROOT/etc/sysupdate.d/feata.feature" <<EOF
[Feature]
Description=Feature A
EOF
"$SYSUPDATE" --root="$ROOT_FEATURE_ROOT" --component-all enable-feature --feature-all
assert_dropin "$ROOT_FEATURE_ROOT$(feat_enable_dropin_default feata)" yes
rm -rf "$ROOT_FEATURE"
# ---------------------------------------------------------------------------
# Features scoped to a named component, and across all components at once
# ---------------------------------------------------------------------------
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.d /run/sysupdate.compx.d
compfeat_transfer /run/sysupdate.d/01-base.transfer base "$CF/target-default"
compfeat_transfer /run/sysupdate.compx.d/01-compx.transfer compx "$CF/target-compx"
cat >/run/sysupdate.d/feata.feature <<EOF
[Feature]
Description=Default feature A
EOF
cat >/run/sysupdate.compx.d/featx.feature <<EOF
[Feature]
Description=Component X feature
EOF
# --component= scopes feature operations to that component: the drop-in must land
# next to the component's definitions, and the default component is untouched.
"$SYSUPDATE" --component=compx enable-feature --feature-all
assert_dropin "$(feat_enable_dropin_comp compx featx)" yes
test ! -e "$(feat_enable_dropin_default feata)"
# --component-all --feature-all fans out over the default component *and* every
# named component.
rm -rf /etc/sysupdate.d /etc/sysupdate.compx.d
"$SYSUPDATE" --component-all enable-feature --feature-all
assert_dropin "$(feat_enable_dropin_default feata)" yes
assert_dropin "$(feat_enable_dropin_comp compx featx)" yes
# ---------------------------------------------------------------------------
# update --component-all installs every component's update in one invocation
# ---------------------------------------------------------------------------
compfeat_reset
compfeat_source v1
mkdir -p /run/sysupdate.compx.d /run/sysupdate.compy.d
compfeat_transfer /run/sysupdate.compx.d/01-compx.transfer compx "$CF/target-compx"
compfeat_transfer /run/sysupdate.compy.d/01-compy.transfer compy "$CF/target-compy"
"$SYSUPDATE" --component-all --verify=no update
test -f "$CF/target-compx/compx-v1.bin"
test -f "$CF/target-compy/compy-v1.bin"
# A second version must likewise be rolled out to every component at once.
compfeat_source v2
"$SYSUPDATE" --component-all --verify=no update
test -f "$CF/target-compx/compx-v2.bin"
test -f "$CF/target-compy/compy-v2.bin"
# A disabled component must not turn "update --component-all" into a failure:
# it is skipped, while the remaining enabled components are still updated.
"$SYSUPDATE" disable-component compy
compfeat_source v3
"$SYSUPDATE" --component-all --verify=no update
test -f "$CF/target-compx/compx-v3.bin"
test ! -e "$CF/target-compy/compy-v3.bin"
compfeat_cleanup
restore_machine_info
touch /testok