[PATCH v3 12/14] Support updates via systemd-sysupdate

Demi Marie Obenour
2025-11-19T08:21:49Z

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.

Updates require /boot to be mounted so that systemd-sysupdate can update
the unified kernel image. They also require that /tmp is writable so
that they 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. GnuPG currently decompresses signatures, but in the
future it will be replaced by Sequoia which does not.

Signed-off-by: Demi Marie Obenour <demiobenour@gmail.com>
---
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 | 19 ++++-
host/rootfs/default.nix | 14 ++--
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 | 83 ++++++++++++++++++++++
host/rootfs/os-release.in | 15 ++++
lib/config.default.nix | 2 +
lib/config.nix | 11 ++-
lib/fake-update-signing-key.gpg | 3 +
release/live/shell.nix | 3 +-
vm/app/systemd-sysupdate/default.nix | 57 +++++++++++++++
vm/app/systemd-sysupdate/escape-url.awk | 31 ++++++++
.../systemd-sysupdate/populate-transfer-directory | 26 +++++++
19 files changed, 376 insertions(+), 10 deletions(-)

diff --git a/host/rootfs/Makefile b/host/rootfs/Makefile
index 0d71dd1441077c9f30945e5063aacbfc240a9006..fe728b6b385862487dab06d832e79b29d57697a8 100644
--- a/host/rootfs/Makefile
+++ b/host/rootfs/Makefile
@@ -9,6 +9,7 @@ include file-list.mk
ROOT_FS_DIR = build

DIRS = \
+ boot \
dev \
etc/s6-linux-init/env \
etc/s6-linux-init/run-image/configs \
@@ -38,13 +39,15 @@ 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

-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
@@ -62,12 +65,22 @@ $(ROOT_FS): ../../scripts/make-erofs.sh $(PACKAGES_FILE) $(FILES) $(BUILD_FILES)
mkdir -p $(ROOT_FS_DIR) && \
{ \
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 ;\
+ for file in $(BUILD_FILES) $(BUILD_NON_TARGET_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 $(ROOT_FS)

+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 26d6dc3f9a63680bf1867c9769f807dc562c81c9..f0b7e061a1f39b3e70d337ef4fe14c98a8f022c8 100644
--- a/host/rootfs/default.nix
+++ b/host/rootfs/default.nix
@@ -5,6 +5,7 @@
import ../../lib/call-package.nix (
{ callSpectrumPackage, spectrum-build-tools, src
, pkgsMusl, pkgsStatic, linux_latest
+, config
}:
pkgsStatic.callPackage (

@@ -13,6 +14,7 @@ pkgsStatic.callPackage (
, busybox, cloud-hypervisor, cryptsetup, dbus, execline, inkscape
, iproute2, inotify-tools, jq, mdevd, s6, s6-linux-init, socat
, util-linuxMinimal, virtiofsd, xorg, xdg-desktop-portal-spectrum-host
+, btrfs-progs
}:

let
@@ -33,8 +35,8 @@ let
foot = pkgsGui.foot.override { allowPgo = false; };

packages = [
- cloud-hypervisor cryptsetup dbus execline inotify-tools iproute2
- jq mdevd s6 s6-linux-init s6-rc socat spectrum-host-tools
+ btrfs-progs cloud-hypervisor cryptsetup dbus execline inotify-tools
+ iproute2 jq mdevd s6 s6-linux-init s6-rc socat spectrum-host-tools
virtiofsd xdg-desktop-portal-spectrum-host

(busybox.override {
@@ -80,11 +82,13 @@ let
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
@@ -96,8 +100,7 @@ let
done

# If systemd-pull is missing systemd-sysupdate will fail with a
- # very confusing error message. If systemd-sysupdate doesn't work,
- # users will not be able to receive an update that fixes the problem.
+ # very confusing error message.
for i in sysupdate pull; do
if ! cat -- "$out/usr/lib/systemd/systemd-$i" > /dev/null; then
echo "link to systemd-$i didn't get installed" >&2
@@ -147,6 +150,9 @@ stdenvNoCC.mkDerivation {
printf "%s\n/\n" ${packagesSysroot} >$out
sed p ${writeClosure [ packagesSysroot] } >>$out
'';
+ UPDATE_SIGNING_KEY = config.update-signing-key;
+ UPDATE_URL = config.update-url;
+ VERSION = config.version;
};

# The Makefile uses $(ROOT_FS_DIR), not $(dest), so it can share code
diff --git a/host/rootfs/file-list.mk b/host/rootfs/file-list.mk
index 9acaa1d90bed674814775becf89c1c847d0ce3e3..dc2f3f353b62931cb046e13aa757528a4587025f 100644
--- a/host/rootfs/file-list.mk
+++ b/host/rootfs/file-list.mk
@@ -42,13 +42,20 @@ FILES = \
image/etc/s6-linux-init/run-image/service/xdg-desktop-portal-spectrum-host/template/notification-fd \
image/etc/s6-linux-init/run-image/service/xdg-desktop-portal-spectrum-host/template/run \
image/etc/s6-linux-init/scripts/rc.init \
+ 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 6a82ecc85090a37b13603b29f74ca6e554a28c33..36e073c58b91e81d63ae40a6aa6f019a2f03e546 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 defaults,gid=4,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..ad598b557ac1cc4e9b95ff65a53a68f04d3759ee
--- /dev/null
+++ b/host/rootfs/image/usr/bin/spectrum-update
@@ -0,0 +1,83 @@
+#!/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 {
+ # rm -f ensures that "snapshot" does not exist afterwards.
+ ifte { exit 0 } { rm -f snapshot }
+ # TODO: suppress only "subvolume does not exist" errors.
+ redirfd -w 2 /dev/null btrfs subvolume delete snapshot
+ }
+
+ backtick -E update_vm_id {
+ backtick -E id_path { readlink /run/vm/by-name/sys.appvm-systemd-sysupdate }
+ basename -- $id_path
+ }
+
+ # $fsdir is read-only to the guest, but read-write to the host.
+ # Directories bind-mounted into it are read-write to the guest.
+ # See etc/s6-linux-init/run-image/service/vhost-user-fs/template/run
+ # for details.
+
+ # Set up /etc with what the VM needs. The VM will overlay this
+ # on its own /etc.
+ 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.
+ 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 }
+
+ # Perform the update in a separate mount namespace.
+ 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..e53b01f1259543b988458a14b3014eb8ca29e90d 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";
+ update-url = "https://your-spectrum-os-update-server.invalid/download-directory";
+ update-signing-key = ./fake-update-signing-key.gpg;
}
diff --git a/lib/config.nix b/lib/config.nix
index e437cdbe9aa22dd0f9c8d7052ac331c8fccf6ce6..d7edb967f339f2d0af97adef5c0302eb58950d19 100644
--- a/lib/config.nix
+++ b/lib/config.nix
@@ -1,5 +1,6 @@
-# SPDX-FileCopyrightText: 2023 Alyssa Ross <hi@alyssa.is>
# SPDX-License-Identifier: MIT
+# SPDX-FileCopyrightText: 2023 Alyssa Ross <hi@alyssa.is>
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>

let
customConfigPath = builtins.tryEval <spectrum-config>;
@@ -17,6 +18,12 @@ let
callConfig = config: if builtins.typeOf config == "lambda" then config {
inherit default;
} else config;
+ finalConfig = default // callConfig config;
in

-default // callConfig config
+finalConfig // {
+ update-signing-key = builtins.path {
+ name = "signing-key";
+ path = finalConfig.update-signing-key;
+ };
+}
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/release/live/shell.nix b/release/live/shell.nix
index ffaa9a571c662810348822a5952d479d251a25e5..b263eacc4d0324191e3c7737dd90d304e477e79b 100644
--- a/release/live/shell.nix
+++ b/release/live/shell.nix
@@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: 2021-2024 Alyssa Ross <hi@alyssa.is>

import ../../lib/call-package.nix (
-{ callSpectrumPackage, stdenv, qemu_kvm }:
+{ callSpectrumPackage, config, stdenv, qemu_kvm }:

let
efi = callSpectrumPackage ../../host/efi.nix {};
@@ -17,6 +17,7 @@ in
OVMF_CODE = "${qemu_kvm}/share/qemu/edk2-${stdenv.hostPlatform.qemuArch}-code.fd";
ROOT_FS_DIR = efi.rootfs;
EFI_IMAGE = efi;
+ VERSION = config.version;
};
}
)) (_: {})
diff --git a/vm/app/systemd-sysupdate/default.nix b/vm/app/systemd-sysupdate/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..04df283f09a1f1ece9197e275d562193af170982
--- /dev/null
+++ b/vm/app/systemd-sysupdate/default.nix
@@ -0,0 +1,57 @@
+# 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
+ escape-url = builtins.path {
+ name = "escape-url";
+ path = ./escape-url.awk;
+ };
+ populate-transfer-directory = builtins.path {
+ name = "populate-transfer-directory";
+ path = ./populate-transfer-directory;
+ };
+in
+
+callSpectrumPackage ../../make-vm.nix {} {
+ providers.net = [ "sys.netvm" ];
+ type = "nix";
+ run = writeScript "run-script" ''
+#!/usr/bin/execlineb -P
+export LC_ALL C
+export LANGUAGE C
+if { mount -toverlay -olowerdir=/run/virtiofs/virtiofs0/etc:/etc -- overlay /etc }
+backtick tmpdir { mktemp -d /run/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 }
+# Leading and trailing whitespace is almost certainly user error,
+# but be friendly to the user (by stripping it) rather than failing.
+backtick update_url {
+ awk "BEGIN {
+ url = ENVIRON[\"update_url\"]
+ gsub(/^[[:space:]]+/, \"\", url)
+ gsub(/[[:space:]]+$/, \"\", url)
+ print url
+ }"
+}
+multisubstitute {
+ importas -iSu tmpdir
+ importas -iSu update_url
+}
+if { ${populate-transfer-directory} ${escape-url} /etc/vm-sysupdate.d ''${tmpdir} ''${update_url} }
+if { ${systemd}/lib/systemd/systemd-sysupdate --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. Only allow HTTP and HTTPS protocols on redirection.
+if { ${curl}/bin/curl -L --proto-redir =http,https --globoff
+ -o /run/virtiofs/virtiofs0/updates/SHA256SUMS -- ''${update_url}/SHA256SUMS }
+${curl}/bin/curl -L --proto-redir =http,https --globoff
+ -o /run/virtiofs/virtiofs0/updates/SHA256SUMS.sha256.asc -- ''${update_url}/SHA256SUMS.sha256.asc
+'';
+}) (_: {})
diff --git a/vm/app/systemd-sysupdate/escape-url.awk b/vm/app/systemd-sysupdate/escape-url.awk
new file mode 100644
index 0000000000000000000000000000000000000000..8edd816a20ceefa08ecc7f1bc2d1cfbe33fa8a89
--- /dev/null
+++ b/vm/app/systemd-sysupdate/escape-url.awk
@@ -0,0 +1,31 @@
+#!/usr/bin/awk -f
+# SPDX-License-Identifier: EUPL-1.2+
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+BEGIN {
+ update_url = ARGV[1];
+ # Check for a GNU awk misfeature
+ newline = "\n";
+ # 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.
+ if (update_url ~ /^[^\001-\040?#\x7F]+$/) {
+ # Backslashes are special to systemd-sysupdate.
+ # Use \\\\& because without the & the result is
+ # not portable between GNU awk and non-GNU awk.
+ gsub(/\\/, "\\\\&", update_url);
+ # "&" and "\\" are special on the RHS of a sed substitution
+ # and must be escaped with another backslash. The delimiter
+ # ("#" in this case) and "\n" must also be escaped, but they
+ # were rejected above so don't bother.
+ gsub(/[&\\]/, "\\\\&", update_url);
+ printf "%s", update_url;
+ exit 0;
+ } else {
+ print "Bad update URL from host: control characters, whitespace, query parameters, and fragment specifiers not allowed" > "/dev/stderr";
+ exit 100;
+ }
+}
diff --git a/vm/app/systemd-sysupdate/populate-transfer-directory b/vm/app/systemd-sysupdate/populate-transfer-directory
new file mode 100755
index 0000000000000000000000000000000000000000..f8e515c1a69b5a6a292cc3a4d387d501f1c6a3fe
--- /dev/null
+++ b/vm/app/systemd-sysupdate/populate-transfer-directory
@@ -0,0 +1,26 @@
+#!/usr/bin/env -S execlineb -WS4
+# SPDX-License-Identifier: EUPL-1.2+
+# SPDX-FileCopyrightText: 2025 Demi Marie Obenour <demiobenour@gmail.com>
+# $1: awk script name
+# $2: transfer directory
+# $3: target directory
+# $4: update URL
+export LC_ALL C
+export LANGUAGE C
+backtick -N sed_rhs {
+ # Use awk to both validate the URL and to escape sed metacharacters.
+ awk -f $1 -- $4
+}
+export tmpdir $3
+elglob -w -0 transfer_file_ ${2}/*.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 source $transfer_file
+}
+redirfd -w 1 ${tmpdir}/${target_basename} sed -E -- "s#@UPDATE_URL@#${sed_rhs}#g" $source

--
2.52.0