[PATCH v6 6/8] Support updates via systemd-sysupdate

Demi Marie Obenour
2025-11-29T09:51:30Z

Include a new `spectrum-update` command to update the system. This
tells the new sys.appvm-systemd-sysupdate VM to download the updates
into a staging directory using systemd-sysupdate. The host then runs
systemd-sysupdate to apply the updates itself.

sys.appvm-systemd-sysupdate uses host-provided information to fetch the
update. This allows editing files on the host to change the update URL
and signing key.

systemd-sysupdate requires /boot to be mounted so that systemd-sysupdate
can update the unified kernel image. systemd-sysupdate also requires
that /tmp is writable so that it can store temporary files, so put a
tmpfs there. Furthermore, there needs to be a directory for storing
downloaded updates. Create /home so that users can mount their
persistent data there.

The directory the VM downloads updates into is *not* reset (wiped)
before or after the update. This allows the VM to know if the system is
already up to date. Otherwise, it would redownload the entire
multi-gigabyte update image.

Updates are currently not compressed. This should be changed in the
future, but it would add a small amount of additional complexity. In
particular, the script generating the update directory would need to
generate a SHA256SUMS containing the hash of both the compressed and
uncompressed versions. More importantly, the VM must not be able to
make the host use the compressed version. This would be a potential
security risk because decompression happens before signature
verification.

Signed-off-by: Demi Marie Obenour <demiobenour@gmail.com>
Reviewed-by: Alyssa Ross <hi@alyssa.is>
---
Changes since v5:
- Remove statement about GnuPG that does not apply to the version in
Nixpkgs.
- Delete stale comment.
- Rebase and fix merge conflict.

Changes since v4:
- Do not strip leading and trailing whitespace from update URLs.
- Create a single script that does the work. Pass the paths to curl and
systemd sysupdate to it as environment variables. Inline the awk
script into it.
- Rebase and fix merge conflict.

Changes since v3:

- Move builtins.path from lib/config.nix to host/rootfs/default.nix.
- Change config options from "update-url" to "updateUrl" and
"update-signing-key" to "updateSigningKey".

Changes since v2:

- Generate the transfer files in the guest, not the host.
- Do not use an environment file.
- Reject URLs that cannot work.
- Escape sed metacharacters.
- Escape backslashes for systemd-sysupdate.
- Do not validate update URLs at build time, only at runtime.
- Reject only update URLs that cannot possibly work.
- Set the partition UUIDs according to systemd's recommendation.
- Do not rely on finding partitions by label.
- Strip leading and trailing whitespace from the update URL.
- Rename the update command from `update` to `spectrum-update`.
- Delete the list of steps. Replace it with comments in the script.

The awk script in the VM rejects URLs that contain whitespace. This is
because they can't work, and passing them to systemd-sysupdate would
require figuring out how to escape them. Rejecting such bogus URLs is
simpler than preventing them from being mangled.
---
host/rootfs/Makefile | 17 ++++-
host/rootfs/default.nix | 16 +++-
host/rootfs/file-list.mk | 7 ++
host/rootfs/image/etc/fstab | 1 +
.../image/etc/sysupdate.d/50-verity.transfer | 20 +++++
host/rootfs/image/etc/sysupdate.d/60-root.transfer | 20 +++++
.../image/etc/sysupdate.d/70-kernel.transfer | 20 +++++
.../image/etc/vm-sysupdate.d/50-verity.transfer | 18 +++++
.../image/etc/vm-sysupdate.d/60-root.transfer | 18 +++++
.../image/etc/vm-sysupdate.d/70-kernel.transfer | 18 +++++
host/rootfs/image/usr/bin/spectrum-update | 87 ++++++++++++++++++++++
host/rootfs/os-release.in | 15 ++++
lib/config.default.nix | 2 +
lib/fake-update-signing-key.gpg | 3 +
vm/app/systemd-sysupdate/default.nix | 26 +++++++
vm/app/systemd-sysupdate/download-update | 68 +++++++++++++++++
16 files changed, 350 insertions(+), 6 deletions(-)

diff --git a/host/rootfs/Makefile b/host/rootfs/Makefile
index 065722d48951a17182ed94e168796700652db7b9..c9c51a06962156d0fc1b2823bc3edb1678c70502 100644
--- a/host/rootfs/Makefile
+++ b/host/rootfs/Makefile
@@ -10,6 +10,7 @@ include file-list.mk
ROOT_FS = build

DIRS = \
+ boot \
dev \
etc/s6-linux-init/env \
etc/s6-linux-init/run-image/configs \
@@ -33,15 +34,17 @@ DIRS = \
etc/s6-linux-init/run-image/vm/by-id \
etc/s6-linux-init/run-image/vm/by-name \
ext \
+ home \
proc \
run \
- sys
+ sys \
+ tmp

FIFOS = \
etc/s6-linux-init/run-image/service/s6-svscan-log/fifo \
etc/s6-linux-init/run-image/service/s6-linux-init-shutdownd/fifo

-BUILD_FILES = build/etc/s6-rc
+BUILD_FILES = build/etc/s6-rc build/etc/os-release build/etc/update-url

# This rule produces three files but Make only (portably)
# supports one output per rule. Instead of resorting to temporary
@@ -63,12 +66,22 @@ $(ROOT_FS_IMAGE): ../../scripts/make-erofs.sh $(PACKAGES_FILE) $(FILES) $(BUILD_
mkdir -p $(ROOT_FS) && \
{ \
cat $(PACKAGES_FILE) ;\
+ printf '%s\n%s\n' "$$UPDATE_SIGNING_KEY" /etc/systemd/import-pubring.gpg; \
for file in $(FILES) $(LINKS); do printf '%s\n%s\n' $$file "$${file#image/}"; done ;\
for file in $(BUILD_FILES); do printf '%s\n%s\n' $$file $${file#build/}; done ;\
printf 'build/empty\n%s\n' $(DIRS) ;\
printf 'build/fifo\n%s\n' $(FIFOS) ;\
} | ../../scripts/make-erofs.sh $@

+build/etc/update-url:
+ mkdir -p build/etc
+ # might have metacharacters, so avoid interpolation
+ printf %s\\n "$${UPDATE_URL:?'update URL empty or missing'}" > build/etc/update-url
+
+build/etc/os-release:
+ mkdir -p build/etc
+ sed 's/@VERSION@/$(VERSION)/g' < os-release.in > build/etc/os-release
+
build/fifo:
mkdir -p build
mkfifo -m 0600 $@
diff --git a/host/rootfs/default.nix b/host/rootfs/default.nix
index 4fe9058abdfaa1df7d63b84a629708d4d99388f4..b441a517f3bbb78f84d8566ca6dfd9181d0302be 100644
--- a/host/rootfs/default.nix
+++ b/host/rootfs/default.nix
@@ -15,6 +15,7 @@ pkgsMusl.callPackage (
, jq, kmod, mdevd, mesa, s6, s6-linux-init, socat, systemd
, util-linuxMinimal, virtiofsd, westonLite, xdg-desktop-portal
, xdg-desktop-portal-gtk, xdg-desktop-portal-spectrum-host
+, btrfs-progs
}:

let
@@ -24,9 +25,9 @@ let
trivial;

packages = [
- cloud-hypervisor cosmic-files crosvm cryptsetup dbus execline
- fuse3 inotify-tools iproute2 jq kmod mdevd s6 s6-linux-init s6-rc
- socat spectrum-host-tools systemd util-linuxMinimal virtiofsd
+ btrfs-progs cloud-hypervisor cosmic-files crosvm cryptsetup dbus
+ execline fuse3 inotify-tools iproute2 jq kmod mdevd s6 s6-linux-init
+ s6-rc socat spectrum-host-tools util-linuxMinimal virtiofsd
xdg-desktop-portal-spectrum-host

(foot.override { allowPgo = false; })
@@ -56,18 +57,20 @@ let
# https://inbox.vuxu.org/musl/20251017-dlopen-use-rpath-of-caller-dso-v1-1-46c69eda1473@iscas.ac.cn/
usrPackages = [
appvm dejavu_fonts kmod.lib mesa westonLite kernel.modules
- firmware netvm
+ firmware netvm systemd
];

appvms = {
appvm-firefox = callSpectrumPackage ../../vm/app/firefox.nix {};
appvm-foot = callSpectrumPackage ../../vm/app/foot.nix {};
appvm-gnome-text-editor = callSpectrumPackage ../../vm/app/gnome-text-editor.nix {};
+ appvm-systemd-sysupdate = callSpectrumPackage ../../vm/app/systemd-sysupdate {};
};

packagesSysroot = runCommand "packages-sysroot" {
depsBuildBuild = [ inkscape ];
nativeBuildInputs = [ xorg.lndir ];
+ src = builtins.path { name = "os-release"; path = ./os-release.in; };
} ''
mkdir -p $out/usr/bin $out/usr/share/dbus-1/services \
$out/usr/share/icons/hicolor/20x20/apps
@@ -118,6 +121,11 @@ stdenvNoCC.mkDerivation {
printf "%s\n/\n" ${packagesSysroot} >$out
sed p ${writeClosure [ packagesSysroot] } >>$out
'';
+ UPDATE_SIGNING_KEY = builtins.path {
+ name = "signing-key";
+ path = config.updateSigningKey;
+ };
+ UPDATE_URL = config.updateUrl;
VERSION = config.version;
};

diff --git a/host/rootfs/file-list.mk b/host/rootfs/file-list.mk
index 613a9e7c692c8a3b1d556c3cd599ffaccf4372c4..56f693e53b918751bcc4b8614f027edde6312040 100644
--- a/host/rootfs/file-list.mk
+++ b/host/rootfs/file-list.mk
@@ -41,13 +41,20 @@ FILES = \
image/etc/s6-linux-init/scripts/rc.init \
image/etc/s6-linux-init/scripts/rc.shutdown \
image/etc/s6-linux-init/scripts/rc.shutdown.final \
+ image/etc/sysupdate.d/50-verity.transfer \
+ image/etc/sysupdate.d/60-root.transfer \
+ image/etc/sysupdate.d/70-kernel.transfer \
image/etc/udev/rules.d/99-spectrum.rules \
+ image/etc/vm-sysupdate.d/50-verity.transfer \
+ image/etc/vm-sysupdate.d/60-root.transfer \
+ image/etc/vm-sysupdate.d/70-kernel.transfer \
image/etc/xdg/weston/autolaunch \
image/etc/xdg/weston/weston.ini \
image/usr/bin/assign-devices \
image/usr/bin/create-vm-dependencies \
image/usr/bin/run-appimage \
image/usr/bin/run-vmm \
+ image/usr/bin/spectrum-update \
image/usr/bin/vm-console \
image/usr/bin/vm-import \
image/usr/bin/vm-start \
diff --git a/host/rootfs/image/etc/fstab b/host/rootfs/image/etc/fstab
index 5dc9b2a3c4dff62ee49b2d827f53b45b7781a60f..6230d910a23339925fea0f2ffbc2baa5241ce3f2 100644
--- a/host/rootfs/image/etc/fstab
+++ b/host/rootfs/image/etc/fstab
@@ -4,3 +4,4 @@ proc /proc proc defaults 0 0
devpts /dev/pts devpts gid=5,mode=620 0 0
tmpfs /dev/shm tmpfs defaults 0 0
sysfs /sys sysfs defaults 0 0
+tmpfs /tmp tmpfs defaults 0 0
diff --git a/host/rootfs/image/etc/sysupdate.d/50-verity.transfer b/host/rootfs/image/etc/sysupdate.d/50-verity.transfer
new file mode 100644
index 0000000000000000000000000000000000000000..9cd64b58ae55d55d378d99f5701f1ecef867e436
--- /dev/null
+++ b/host/rootfs/image/etc/sysupdate.d/50-verity.transfer
@@ -0,0 +1,20 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+
+# Uses example code from systemd man pages which is under MIT-0
+# (no attribution required).
+[Transfer]
+ProtectVersion=%A
+
+[Source]
+Type=url-file
+Path=file:///run/updater
+MatchPattern=Spectrum_@v_@u.verity
+
+[Target]
+Type=partition
+Path=auto
+MatchPattern=Spectrum_@v.verity
+MatchPartitionType=root-verity
+PartitionFlags=0
+ReadOnly=1
diff --git a/host/rootfs/image/etc/sysupdate.d/60-root.transfer b/host/rootfs/image/etc/sysupdate.d/60-root.transfer
new file mode 100644
index 0000000000000000000000000000000000000000..cd12d2bd2b4ecd9bb5c7d26cc7c27a4bdb74cac8
--- /dev/null
+++ b/host/rootfs/image/etc/sysupdate.d/60-root.transfer
@@ -0,0 +1,20 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+
+# Uses example code from systemd man pages which is under MIT-0
+# (no attribution required).
+[Transfer]
+ProtectVersion=%A
+
+[Source]
+Type=url-file
+Path=file:///run/updater
+MatchPattern=Spectrum_@v_@u.root
+
+[Target]
+Type=partition
+Path=auto
+MatchPattern=Spectrum_@v
+MatchPartitionType=root
+PartitionFlags=0
+ReadOnly=1
diff --git a/host/rootfs/image/etc/sysupdate.d/70-kernel.transfer b/host/rootfs/image/etc/sysupdate.d/70-kernel.transfer
new file mode 100644
index 0000000000000000000000000000000000000000..e4190587a6bb127cb7315f38d59e48cf279318a4
--- /dev/null
+++ b/host/rootfs/image/etc/sysupdate.d/70-kernel.transfer
@@ -0,0 +1,20 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+
+# Uses example code from systemd man pages which is under MIT-0
+# (no attribution required).
+[Transfer]
+ProtectVersion=%A
+
+[Source]
+Type=url-file
+Path=file:///run/updater
+MatchPattern=Spectrum_@v.efi
+
+[Target]
+Type=regular-file
+Path=/EFI/Linux
+PathRelativeTo=boot
+MatchPattern=Spectrum_@v.efi
+Mode=0644
+InstancesMax=2
diff --git a/host/rootfs/image/etc/vm-sysupdate.d/50-verity.transfer b/host/rootfs/image/etc/vm-sysupdate.d/50-verity.transfer
new file mode 100644
index 0000000000000000000000000000000000000000..ab4997c83605e3820a22b0b2178dcd76dfcf787e
--- /dev/null
+++ b/host/rootfs/image/etc/vm-sysupdate.d/50-verity.transfer
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+
+# Uses example code from systemd man pages which is under MIT-0
+# (no attribution required).
+[Transfer]
+Verify=yes
+
+[Source]
+Type=url-file
+Path=@UPDATE_URL@
+MatchPattern=Spectrum_@v_@u.verity
+
+[Target]
+Type=regular-file
+Path=/run/virtiofs/virtiofs0/updates
+MatchPattern=Spectrum_@v_@u.verity
+Mode=0644
diff --git a/host/rootfs/image/etc/vm-sysupdate.d/60-root.transfer b/host/rootfs/image/etc/vm-sysupdate.d/60-root.transfer
new file mode 100644
index 0000000000000000000000000000000000000000..8a3175684f1697e0eca443eb0a1a97176e4f66d4
--- /dev/null
+++ b/host/rootfs/image/etc/vm-sysupdate.d/60-root.transfer
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+
+# Uses example code from systemd man pages which is under MIT-0
+# (no attribution required).
+[Transfer]
+Verify=yes
+
+[Source]
+Type=url-file
+Path=@UPDATE_URL@
+MatchPattern=Spectrum_@v_@u.root
+
+[Target]
+Type=regular-file
+Path=/run/virtiofs/virtiofs0/updates
+MatchPattern=Spectrum_@v_@u.root
+Mode=0644
diff --git a/host/rootfs/image/etc/vm-sysupdate.d/70-kernel.transfer b/host/rootfs/image/etc/vm-sysupdate.d/70-kernel.transfer
new file mode 100644
index 0000000000000000000000000000000000000000..cb181239d71c5a6d0a5b3652d5534a23eda64183
--- /dev/null
+++ b/host/rootfs/image/etc/vm-sysupdate.d/70-kernel.transfer
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+
+# Uses example code from systemd man pages which is under MIT-0
+# (no attribution required).
+[Transfer]
+Verify=yes
+
+[Source]
+Type=url-file
+Path=@UPDATE_URL@
+MatchPattern=Spectrum_@v.efi
+
+[Target]
+Type=regular-file
+Path=/run/virtiofs/virtiofs0/updates
+MatchPattern=Spectrum_@v.efi
+Mode=0644
diff --git a/host/rootfs/image/usr/bin/spectrum-update b/host/rootfs/image/usr/bin/spectrum-update
new file mode 100755
index 0000000000000000000000000000000000000000..bf5b0b0c50cb5381af177c6df6a05d215f775489
--- /dev/null
+++ b/host/rootfs/image/usr/bin/spectrum-update
@@ -0,0 +1,87 @@
+#!/bin/execlineb -WS1
+# SPDX-License-Identifier: EUPL-1.2+
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+
+if { mkdir -p -m 0700 /run/updater }
+
+# Take a global lock to avoid races.
+s6-setlock /run/update-lock
+
+foreground { redirfd -w 2 /dev/null rmdir -- $1 }
+if { umask 0077 mkdir -p -- $1 }
+cd $1
+foreground {
+ # If this exists already that is okay.
+ foreground { redirfd -w 2 /dev/null btrfs subvolume create -- shared }
+
+ # Delete any stale temporary files. Delete any existing signature
+ # files. If the VM is still running (it should not be), the VM might
+ # have write access to the directory. However, updates-dir-check is
+ # safe against that.
+ if { updates-dir-check cleanup shared }
+
+ if {
+ foreground {
+ # TODO: suppress only "subvolume does not exist" errors.
+ redirfd -w 2 /dev/null
+ btrfs subvolume delete snapshot
+ }
+ rm -f snapshot
+ }
+
+ backtick -E update_vm_id {
+ backtick -E id_path { readlink /run/vm/by-name/sys.appvm-systemd-sysupdate }
+ basename -- $id_path
+ }
+
+ # Set up /etc with what the VM needs. The VM will overlay this
+ # on its own /etc.
+ #
+ # In the future, this should use a bind mount instead of copying
+ # into a tmpfs. However, this would significantly complicate the
+ # cleanup code. Deleting fs/etc would require undoing the bind
+ # mounts instead of rm -rf. Once this code is in a separate mount
+ # namespace, the copies should be replaced by bind mounts.
+ if {
+ if { rm -rf -- /run/vm/by-id/${update_vm_id}/fs/etc }
+ umask 022
+ if { mkdir -p -- /run/vm/by-id/${update_vm_id}/fs/updates /run/vm/by-id/${update_vm_id}/fs/etc/systemd }
+ if { cp -R -- /etc/vm-sysupdate.d /etc/update-url /run/vm/by-id/${update_vm_id}/fs/etc }
+ cp -- /etc/systemd/import-pubring.gpg /run/vm/by-id/${update_vm_id}/fs/etc/systemd
+ }
+
+ # If the directory is already mounted, unmount it. This prevents a
+ # confusing error from mount.
+ foreground { redirfd -w 2 /dev/null umount -- /run/vm/by-id/${update_vm_id}/fs/updates }
+
+ # Share the update directory with the VM.
+ if { mount --bind -- shared /run/vm/by-id/${update_vm_id}/fs/updates }
+
+ # Start the update VM.
+ if { vm-start $update_vm_id }
+
+ # Wait for the VM to exit.
+ # TODO: This is racy. If the update finishes before this code runs,
+ # the s6-svwait call will fail.
+ if { s6-svwait -D /run/service/vmm/instance/${update_vm_id} }
+
+ # Remove the bind mount.
+ if { umount -- /run/vm/by-id/${update_vm_id}/fs/updates }
+
+ # Ensure that the VM cannot change the directory
+ # while systemd-sysupdate is using it.
+ if { btrfs subvolume snapshot -- shared snapshot }
+
+ # Validate the update directory. Delete any stale temporary files.
+ # Check that a signature file was downloaded.
+ if { updates-dir-check check snapshot }
+
+ unshare --mount
+ if { mount --bind -o ro -- snapshot /run/updater }
+
+ /usr/lib/systemd/systemd-sysupdate update
+}
+importas -i sysupdate_exit_status ?
+# Clean up.
+foreground { btrfs subvolume delete -- snapshot }
+exit $sysupdate_exit_status
diff --git a/host/rootfs/os-release.in b/host/rootfs/os-release.in
new file mode 100644
index 0000000000000000000000000000000000000000..d6e699e82f87dcb1c4656ac19d4e9986282f14a5
--- /dev/null
+++ b/host/rootfs/os-release.in
@@ -0,0 +1,15 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+NAME="Spectrum OS"
+ID=spectrum
+PRETTY_NAME="Spectrum @VERSION@"
+VERSION=@VERSION@
+VERSION_ID=@VERSION@
+IMAGE_ID=spectrum-root
+IMAGE_VERSION=@VERSION@
+RELEASE_TYPE=development
+HOME_URL="https://spectrum-os.org"
+BUG_REPORT_URL="mailto:discuss@spectrum-os.org"
+ANSI_COLOR="1;34"
+VENDOR_NAME=Spectrum
+VENDOR_URL="https://spectrum-os.org"
diff --git a/lib/config.default.nix b/lib/config.default.nix
index 489c231490a8b66aa01f50053b25646060f7f963..f6b70fa5e8431bef79222c10c79e8015f7fe65be 100644
--- a/lib/config.default.nix
+++ b/lib/config.default.nix
@@ -5,4 +5,6 @@
pkgsFun = import ./nixpkgs.default.nix;
pkgsArgs = {};
version = "0.0.0";
+ updateUrl = "https://your-spectrum-os-update-server.invalid/download-directory";
+ updateSigningKey = ./fake-update-signing-key.gpg;
}
diff --git a/lib/fake-update-signing-key.gpg b/lib/fake-update-signing-key.gpg
new file mode 100644
index 0000000000000000000000000000000000000000..12e18f4c7c740e31692e1f1975282fa72ac1f2e3
--- /dev/null
+++ b/lib/fake-update-signing-key.gpg
@@ -0,0 +1,3 @@
+SPDX-License-Identifier: CC0-1.0
+SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+NOT A VALID KEY - UPDATES WILL NOT WORK
diff --git a/vm/app/systemd-sysupdate/default.nix b/vm/app/systemd-sysupdate/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..db64f0105da6e1f9951d13f67a2ca8527ae6f73d
--- /dev/null
+++ b/vm/app/systemd-sysupdate/default.nix
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: MIT
+# SPDX-FileCopyrightText: 2023 Alyssa Ross <hi@alyssa.is>
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+
+import ../../../lib/call-package.nix (
+{ callSpectrumPackage, curl, lib, src
+, runCommand, systemd, writeScript
+}:
+
+let
+ downloadUpdate = builtins.path {
+ name = "download-update";
+ path = ./download-update;
+ };
+in
+
+callSpectrumPackage ../../make-vm.nix {} {
+ providers.net = [ "sys.netvm" ];
+ type = "nix";
+ run = writeScript "run-script" ''
+#!/usr/bin/execlineb -WS0
+export CURL_PATH ${curl}/bin/curl
+export SYSTEMD_SYSUPDATE_PATH ${systemd}/lib/systemd/systemd-sysupdate
+${downloadUpdate}
+'';
+}) (_: {})
diff --git a/vm/app/systemd-sysupdate/download-update b/vm/app/systemd-sysupdate/download-update
new file mode 100755
index 0000000000000000000000000000000000000000..eada41c6c8ad5edcedd9f4d76b76492e0b8be826
--- /dev/null
+++ b/vm/app/systemd-sysupdate/download-update
@@ -0,0 +1,68 @@
+#!/usr/bin/env -S execlineb -WS0
+# SPDX-License-Identifier: EUPL-1.2+
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+export LC_ALL C
+export LANGUAGE C
+if { mount -toverlay -olowerdir=/run/virtiofs/virtiofs0/etc:/etc -- overlay /etc }
+backtick tmpdir { mktemp -d /tmp/sysupdate-XXXXXX }
+# Not a useless use of cat: if there are NUL bytes in the URL
+# busybox's awk might misbehave.
+backtick update_url { cat /etc/update-url }
+if {
+ backtick sed_rhs {
+ # Use awk to both validate the URL and to escape sed metacharacters.
+ # Reject URLs with control characters, query parameters, or fragments.
+ # They *cannot* work and so are rejected to produce better error messages.
+ #
+ # curl rejects control characters with "Malformed input to a URL function".
+ # Fragment specifiers ("#") and query parameters ("?") break concatenating
+ # /SHA256SUMS and /SHA256SUMS.sha256.asc onto the update URL. Also, it is
+ # simpler to reject update URLs that contain whitespace than to try to
+ # escape them.
+ #
+ # Backslash needs to be escaped once for systemd-sysupdate and again for sed.
+ # Ampersand needs to be escaped once for sed.
+ awk "BEGIN {
+ update_url = ENVIRON[\"update_url\"];
+ if (update_url ~ /^[^\\001-\\040?#\\x7F]+$/) {
+ # Use & to avoid extra escaping (16 or 32 backslashes!)
+ # and a divergence between POSIX and GNU awk.
+ gsub(/\\\\/, \"&&&&\", update_url);
+ gsub(/&/, \"\\\\\\\\&\", update_url);
+ print update_url;
+ exit 0;
+ } else {
+ print ARGV[2] > \"/dev/stderr\";
+ exit 100;
+ }
+ }" -- $3
+ "Bad update URL from host: control characters, whitespace, query parameters, and fragment specifiers not allowed"
+ }
+ elglob -w -0 transfer_file_ /etc/vm-sysupdate.d/*.transfer
+ forx -E transfer_file { $transfer_file_ }
+ backtick target_basename {
+ basename -- $transfer_file
+ }
+ multisubstitute {
+ importas -iuS sed_rhs
+ importas -iuS target_basename
+ importas -iuS tmpdir
+ define sed_input $transfer_file
+ }
+ redirfd -w 1 ${tmpdir}/${target_basename}
+ sed -E -- "s#@UPDATE_URL@#${sed_rhs}#g" $sed_input
+}
+multisubstitute {
+ importas -iuS update_url
+ importas -iuS CURL_PATH
+ importas -iuS SYSTEMD_SYSUPDATE_PATH
+ importas -iuS tmpdir
+}
+if { $SYSTEMD_SYSUPDATE_PATH --definitions=${tmpdir} update }
+# [ and ] are allowed in update URLs so that IPv6 addresses work, but
+# they cause globbing in the curl command-line tool by default. Use --globoff
+# to disable this feature.
+if { $CURL_PATH -L --proto-redir =http,https --globoff
+ -o /run/virtiofs/virtiofs0/updates/SHA256SUMS -- ${update_url}/SHA256SUMS }
+$CURL_PATH -L --proto-redir =http,https --globoff
+ -o /run/virtiofs/virtiofs0/updates/SHA256SUMS.sha256.asc -- ${update_url}/SHA256SUMS.sha256.asc

--
2.52.0