From d92bf6dd0ae535482cb7f17afb26a4c2ea3508d8 Mon Sep 17 00:00:00 2001
From: Zygmunt Krynicki <zygmunt.krynicki@huawei.com>
Date: Thu, 19 Aug 2021 17:31:39 +0200
Subject: [PATCH] boot/piboot: rewrite PiBoot based on BootConfig

This replaces the scaffolding added early on in the life cycle of the
project, which did crude string replacements and had flawed assumptions,
with a system which deeply understands the boot configuration file and
manipulates it based on the primitives we've built recently. This
approach has prevented us from ever updating any of the relevant OS boot
files with new images.

The new pre-install hook extracts the /boot directory from the system
image and saves it to /boot/slot-{a,b} matching the slot conveyed by
RAUC with the RAUC_SLOT_BOOTNAME_$n (either "A" or "B"). The config.txt
file is analyzed to find the location of the cmdline.txt file, which is
modified to have root= pointing to RAUC_SLOT_DEVICE_$n. This creates a
boot sub-directory with complete set of consistent
kernel/initrd/cmdline/device tree/overlays.

The TrySwitch method queries for the inactive slot, loads the
slot-$inactive/config.txt, pretending that slot-$inactive is the real
/boot directory, as this helps in finding files like cmdline.txt, sets
the os_prefix= to slot-$inactive/ and saves the entire configuration to
tryboot.txt in the real /boot directory.

The QueryActive method changes to look for os_prefix=slot-a/ or
os_prefix=slot-b/. We no longer look at individual parameters or ensure
they have a consistent prefix. Usage of os_prefix= was a long-standing
TODO which is now resolved.

Later on a separate validation step will ensure, that none of the
essential parameters avoids the A/B system or points to non-existent
file.

Apart from piboot, the prepare phase of the spread test suite for piboot
is updated to provide more realistic data. The smoke test is replaced
with a "rauc install" command, exercising the entire stack in one go.

Signed-off-by: Zygmunt Krynicki <zygmunt.krynicki@huawei.com>
---
 boot/piboot/piboot.go                         | 363 +++++++++---------
 boot/piboot/piboot_test.go                    | 358 ++++++++---------
 .../tests/bin/check-slot-a-b-pristine.sh      |  24 ++
 boot/piboot/tests/bundle-install/task.yaml    |  89 +++++
 boot/piboot/tests/cancel-switch/task.yaml     |  23 +-
 boot/piboot/tests/commit-switch/task.yaml     |  20 +-
 boot/piboot/tests/smoke/task.yaml             |  28 --
 boot/piboot/tests/try-switch/task.yaml        |  24 +-
 cspell.json                                   |   1 +
 spread.yaml                                   |  44 ++-
 10 files changed, 549 insertions(+), 425 deletions(-)
 create mode 100755 boot/piboot/tests/bin/check-slot-a-b-pristine.sh
 create mode 100644 boot/piboot/tests/bundle-install/task.yaml
 delete mode 100644 boot/piboot/tests/smoke/task.yaml

diff --git a/boot/piboot/piboot.go b/boot/piboot/piboot.go
index eb0590a..5f714fe 100644
--- a/boot/piboot/piboot.go
+++ b/boot/piboot/piboot.go
@@ -1,15 +1,17 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-FileCopyrightText: Huawei Inc.
 
-// Package piboot implements the bootloader protocol for the Raspberry Pi boot loader.
+// Package piboot implements the boot protocol for the Raspberry Pi.
 //
 // This implementation assumes the following: The raspberry pi boot firmware is
-// at least the release from 2020-10-28. The kernel image and command line files
-// are stored in sub-directories of the firmware boot partition under slot-a/
-// and slot-b/. Each kernel command line file points to the correct slot. The
-// kernel image supports the RPI_FIRMWARE_SET_REBOOT_FLAGS feature.
+// at least the release from 2020-10-28. All boot assets selected by the
+// os_prefix= mechanism are stored in a sub-directory named either slot-a/ or
+// slot-b/, one for each slot.
 //
-// XXX: simplify slot-a/ and slot-b/ detection with the "prefix" variable.
+// Each kernel command line file points root= argument to block device matching
+// each slot. The kernel image supports the RPI_FIRMWARE_SET_REBOOT_FLAGS
+// feature, which is used to perform "tryboot" reboots before commiting the new
+// configuration.
 //
 // References:
 //  - https://github.com/raspberrypi/linux/commit/757666748ebf69dc161a262faa3717a14d68e5aa
@@ -23,78 +25,71 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strconv"
 	"strings"
 
 	"git.ostc-eu.org/OSTC/OHOS/components/sysota/boot"
-	"git.ostc-eu.org/OSTC/OHOS/components/sysota/picfg"
-	"git.ostc-eu.org/OSTC/OHOS/components/sysota/picfg/condfilter"
 	"git.ostc-eu.org/OSTC/OHOS/components/sysota/picfg/pimodel"
 	"git.ostc-eu.org/OSTC/OHOS/components/sysota/rauc/installhandler"
+	"git.ostc-eu.org/OSTC/OHOS/components/sysota/squashfstools/unsquashfs"
 )
 
 const (
-	slotAPrefix = "slot-a/"
-	slotBPrefix = "slot-b/"
-
-	// configTxtName is the name of the firmware configuration file read during the boot process.
+	// configTxtName is the name of the firmware configuration file read during
+	// the boot process.
 	configTxtName = "config.txt"
-
-	// trybootTxtName is the name of the firmware configuration file read during the try-once boot process.
-	// It is further described at https://github.com/raspberrypi/linux/commit/757666748ebf69dc161a262faa3717a14d68e5aa
+	// trybootTxtName is the name of the firmware configuration file read during
+	// the try-once boot process. It is further described at
+	// https://github.com/raspberrypi/linux/commit/757666748ebf69dc161a262faa3717a14d68e5aa
 	trybootTxtName = "tryboot.txt"
-
-	kernelParameter    = "kernel"
-	initramfsParameter = "initramfs"
-	cmdlineParameter   = "cmdline"
+	// argSysotaSlot is a kernel command line argument encoding slot name.
+	argSysotaSlot = "sysota.slot="
+	// argRoot is a kenrel command line argument designating the device with the
+	// root file system.
+	argRoot = "root="
 )
 
+func slotPrefix(slot boot.Slot) string {
+	return fmt.Sprintf("slot-%s/", strings.ToLower(slot.String()))
+}
+
 // PiBoot implements boot.Protocol for the Raspberry Pi boot loader.
 type PiBoot struct {
-	// BootMountPoint determines the mount point of the firmware boot partition.
-	// It is expected that the "config.txt" file is present there.
-	BootMountPoint string
-
-	revCode   pimodel.RevisionCode
-	serialNum pimodel.SerialNumber
-
-	// FilterPredicate decides if a config.txt section should be inspected.
-	//
-	// A non-zero filter predicate can be used to support a single system image
-	// that is compatible with different device types, each with a distinct
-	// kernel image by only considering those configuration sections that apply
-	// to the current device.
-	//
-	// When the filter predicate is not used, section names are not parsed and
-	// unrecognized sections are preserved perfectly. When the predicate is set
-	// all sections must be recognized.
-	//
-	// NOTE(zyga): FilterPredicate is deprecated in favour of (upcoming) usage
-	// of revision code and serial number coupled with condfilter.Simulator to
-	// perform read and write requests.
-	FilterPredicate func([]condfilter.ConditionalFilter) bool
+	bootMountPoint string
+	revCode        pimodel.RevisionCode
+	serialNum      pimodel.SerialNumber
 }
 
-// New returns a new PiBoot with the given firmware boot partition mount point.
-// The specified directory must contain the "config.txt" file.
-//
-// Revision code and serial number are stored but are not yet used.
+// New returns a new PiBoot with the given boot mount point and CPU properties.
 func New(bootMountPoint string, revCode pimodel.RevisionCode, serialNum pimodel.SerialNumber) (*PiBoot, error) {
-	file, err := os.Open(filepath.Join(bootMountPoint, configTxtName))
-	if err != nil {
-		return nil, err
-	}
-
-	if err := file.Close(); err != nil {
-		return nil, err
-	}
-
+	// TODO(zyga): drop the error return path if it is not useful later on.
 	return &PiBoot{
-		BootMountPoint: bootMountPoint,
+		bootMountPoint: bootMountPoint,
 		revCode:        revCode,
 		serialNum:      serialNum,
 	}, nil
 }
 
+// BootMountPoint returns the corresponding value passed to the New.
+func (pi *PiBoot) BootMountPoint() string {
+	return pi.bootMountPoint
+}
+
+// RevisionCode returns the corresponding value passed to the New.
+func (pi *PiBoot) RevisionCode() pimodel.RevisionCode {
+	return pi.revCode
+}
+
+// SerialNumber returns the corresponding value passed to the New.
+func (pi *PiBoot) SerialNumber() pimodel.SerialNumber {
+	return pi.serialNum
+}
+
+// activeBootConfig loads the boot config corresponding to the active slot.
+func (pi *PiBoot) activeBootConfig() (*BootConfig, error) {
+	return NewBootConfig(pi.bootMountPoint, pi.revCode, pi.serialNum)
+}
+
 // UnknownActiveSlotError records problems with determining active slot.
 type UnknownActiveSlotError struct {
 	Err error
@@ -126,67 +121,34 @@ func (e *UnknownInactiveSlotError) Error() string {
 }
 
 var (
+	// ErrIncompatible records incompatible boot configuration
+	ErrIncompatible = errors.New("incompatible boot configuration")
 	// ErrCorruptedConfig records boot configuration deviating from assumptions.
 	ErrCorruptedConfig = errors.New("inconsistent or corrupted configuration")
-	// ErrExpectedSlotAOrSlotB records existence of unexpected slot.
-	ErrExpectedSlotAOrSlotB = errors.New("expected slot A or slot B")
 )
 
 // QueryActive returns the slot that is used for booting.
 //
-// Active slot is determined by inspecting the kernel and cmdline parameters in
-// the last section that is applicable, as decided by the filter predicate. By
-// arbitrary convention, both parameters must have the prefix "slot-a/" or
-// "slot-b/" to be recognized.
-//
+// Active slot is determined by the value of os_prefix= parameter.
 // If there is an error, it will be of type *UnknownActiveSlotError.
 func (pi *PiBoot) QueryActive() (slot boot.Slot, err error) {
-	file, err := os.Open(filepath.Join(pi.BootMountPoint, configTxtName))
+	bootCfg, err := pi.activeBootConfig()
 	if err != nil {
 		return boot.InvalidSlot, &UnknownActiveSlotError{Err: err}
 	}
 
-	defer func() {
-		e := file.Close()
-		if err == nil {
-			err = e
-		}
-	}()
-
-	var configTxt picfg.ConfigTxt
-
-	if err := picfg.NewDecoder(file).Decode(&configTxt); err != nil {
-		return boot.InvalidSlot, &UnknownActiveSlotError{Err: err}
-	}
-
-	// Find the configured kernel and kernel cmdline
-	var kernel, initramfs, cmdline string
-
-	err = visitSections(&configTxt, pi.FilterPredicate, func(sect *picfg.Section) error {
-		for _, param := range sect.Parameters {
-			switch param.Name {
-			case kernelParameter:
-				kernel = param.Value
-			case initramfsParameter:
-				initramfs = param.Value
-			case cmdlineParameter:
-				cmdline = param.Value
-			}
-		}
-		return nil
-	})
+	osPrefix, _, err := bootCfg.ComputedOSPrefix()
 	if err != nil {
 		return boot.InvalidSlot, &UnknownActiveSlotError{Err: err}
 	}
-	// Assuming kernel and cmdline are stored in slot-specific sub-directories,
-	// find the configured slot.
-	switch {
-	case strings.HasPrefix(kernel, slotAPrefix) && strings.HasPrefix(cmdline, slotAPrefix) && strings.HasPrefix(initramfs, slotAPrefix):
+
+	switch osPrefix {
+	case slotPrefix(boot.SlotA):
 		return boot.SlotA, nil
-	case strings.HasPrefix(kernel, slotBPrefix) && strings.HasPrefix(cmdline, slotBPrefix) && strings.HasPrefix(initramfs, slotBPrefix):
+	case slotPrefix(boot.SlotB):
 		return boot.SlotB, nil
 	default:
-		return boot.InvalidSlot, &UnknownActiveSlotError{Err: ErrCorruptedConfig}
+		return boot.InvalidSlot, &UnknownActiveSlotError{Err: ErrIncompatible}
 	}
 }
 
@@ -213,77 +175,34 @@ func (pi *PiBoot) QueryInactive() (boot.Slot, error) {
 }
 
 // TrySwitch configures the boot loader for a one-off boot using the given slot.
-func (pi *PiBoot) TrySwitch(to boot.Slot) (err error) {
-	// Load and decode config.txt
-	configTxtFile, err := os.Open(filepath.Join(pi.BootMountPoint, configTxtName))
-	if err != nil {
-		return err
-	}
-
-	defer func() {
-		e := configTxtFile.Close()
-		if err == nil {
-			err = e
-		}
-	}()
-
-	var configTxt picfg.ConfigTxt
-	if err := picfg.NewDecoder(configTxtFile).Decode(&configTxt); err != nil {
-		return err
-	}
-
-	// Find the values to modify, swapping A and B around
-	err = visitSections(&configTxt, pi.FilterPredicate, func(sect *picfg.Section) error {
-		for paramIdx, param := range sect.Parameters {
-			switch param.Name {
-			case kernelParameter, cmdlineParameter, initramfsParameter:
-				var oldPrefix, newPrefix string
-				switch to {
-				case boot.SlotA:
-					oldPrefix = slotBPrefix
-					newPrefix = slotAPrefix
-				case boot.SlotB:
-					oldPrefix = slotAPrefix
-					newPrefix = slotBPrefix
-				default:
-					continue
-				}
-				sect.Parameters[paramIdx].Value = strings.Replace(param.Value, oldPrefix, newPrefix, -1)
-			}
-		}
-		return nil
-	})
+func (pi *PiBoot) TrySwitch(targetSlot boot.Slot) (err error) {
+	slottedBootDir := slotPrefix(targetSlot)
+
+	// Load boot configuration pretending that slot sub-directory of the boot
+	// partition is the is the real boot partition. This is the state we get
+	// from the pre-install handler.
+	slotBootCfg, err := NewBootConfig(
+		filepath.Join(pi.bootMountPoint, slottedBootDir), pi.revCode, pi.serialNum)
 	if err != nil {
 		return err
 	}
 
-	// Encode and write modified config.txt as tryboot.txt
-	trybootTxtFile, err := os.OpenFile(
-		filepath.Join(pi.BootMountPoint, trybootTxtName),
-		os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
-	if err != nil {
+	// Set the os_prefix= to slot-$inactive/ and add a sysota.slot={A,B} flag to
+	// kernel command line (informative only).
+	if err := slotBootCfg.SetOSPrefix(slottedBootDir); err != nil {
 		return err
 	}
 
-	defer func() {
-		e := trybootTxtFile.Close()
-		if err == nil {
-			err = e
-		}
-	}()
-
-	if err := picfg.NewEncoder(trybootTxtFile).Encode(configTxt); err != nil {
-		return err
-	}
+	// Save the configuration as "tryboot.txt" in the real boot mount point.
+	slotBootCfg.BootMountPoint = pi.bootMountPoint
+	slotBootCfg.ConfigFileName = trybootTxtName
 
-	return nil
+	return slotBootCfg.Save()
 }
 
 // CommitSwitch re-configures a try-mode slot for continuous use.
 func (pi *PiBoot) CommitSwitch() error {
-	return os.Rename(
-		filepath.Join(pi.BootMountPoint, trybootTxtName),
-		filepath.Join(pi.BootMountPoint, configTxtName))
+	return os.Rename(filepath.Join(pi.bootMountPoint, trybootTxtName), filepath.Join(pi.bootMountPoint, configTxtName))
 }
 
 const rebootCommand = "reboot"
@@ -298,45 +217,115 @@ func (pi *PiBoot) Reboot(flags boot.RebootFlags) error {
 	return cmd.Run()
 }
 
-// CancelSwitch removes the try-mode boot configuration file.
+// CancelSwitch removes the try-mode boot configuration and cmdline file.
 func (pi *PiBoot) CancelSwitch() error {
-	if err := os.Remove(filepath.Join(pi.BootMountPoint, trybootTxtName)); err != nil && !os.IsNotExist(err) {
+	if err := os.Remove(filepath.Join(pi.bootMountPoint, trybootTxtName)); err != nil && !os.IsNotExist(err) {
 		return err
 	}
 
+	// TODO(zyga): perhaps remove the slot subdirectory as well?
 	return nil
 }
 
-// visitSection calls visitor function on each section in the config file.
-//
-// An optional predicate function may be used to visit only matching sections.
-// The predicate is called with the effective fiters that are in effect at each
-// phase of the traversal process. Sections can be mutated by the visitor.
-func visitSections(configTxt *picfg.ConfigTxt, pred func([]condfilter.ConditionalFilter) bool, visitor func(sect *picfg.Section) error) error {
-	var tracker condfilter.StateTracker
-
-	for idx := range configTxt.Sections {
-		sect := &configTxt.Sections[idx]
-
-		if pred != nil && sect.Filter != "" {
-			filter, err := condfilter.Unmarshal([]byte(sect.Filter))
-			if err != nil {
-				return err
-			}
-
-			tracker.Filter(filter)
-
-			if !pred(tracker.EffectiveFilters()) {
-				continue
-			}
-		}
+// systemClass is the class of the RAUC image with a SystemOTA root file system + boot partition + kernel image.
+const systemClass = "system"
 
-		if err := visitor(sect); err != nil {
-			return err
-		}
+// ErrWouldClobber reports cases where the pre-install hook would clobber active slot.
+var ErrWouldClobber = errors.New("pre-install hook would clobber active slot")
+
+// PreInstallHandler copies boot assets out of system.img and into the boot partition.
+func (pi *PiBoot) PreInstallHandler(ctx *installhandler.HandlerContext) (err error) {
+	// If the handler is invoked but doesn't find a bundle and a target slot
+	// with "system" class then do nothing without reporting an error. This will
+	// allow systems to use other slots and classes without SystemOTA
+	// interfering with that.
+	systemImgInfo := ctx.ImageWithClass(systemClass)
+	targetSlotInfo := ctx.TargetSlotWithClass(systemClass)
+
+	if systemImgInfo == nil || targetSlotInfo == nil {
+		return nil
 	}
 
-	return nil
+	// Which slot are we targetting?
+	var targetSlot boot.Slot
+	if err := targetSlot.UnmarshalText([]byte(targetSlotInfo.BootName)); err != nil {
+		return err
+	}
+
+	// Sanity check, ensure that we never clobber the active slot. This should
+	// never happen but it's just easy to check so let's check and have less
+	// things to worry about.
+	activeSlot, err := pi.QueryActive()
+	if err != nil {
+		return err
+	}
+
+	if activeSlot == targetSlot {
+		return ErrWouldClobber
+	}
+
+	slottedBootDir := slotPrefix(targetSlot)
+
+	// Remove $boot/{squashfs-root/ and $boot/slot-$inactive to make space for
+	// the unpack/move we are about to do below.
+	if err := os.RemoveAll(filepath.Join(pi.bootMountPoint, slottedBootDir)); err != nil {
+		return err
+	}
+
+	const squashfsRootDir = "squashfs-root"
+	if err := os.RemoveAll(filepath.Join(pi.bootMountPoint, squashfsRootDir)); err != nil {
+		return err
+	}
+
+	// Extract the ($system.img)/boot directory from the system.img squashfs
+	// into $boot/squashfs-root/boot.
+	const bootDir = "boot"
+
+	unsquashfsFrom := filepath.Join(ctx.BundleMountPoint, systemImgInfo.Name)
+	unsquashfsTo := filepath.Join(pi.bootMountPoint, squashfsRootDir)
+	unsquashfsWhat := bootDir
+
+	if ctx.InitialHandlerPID != 0 {
+		// Note that the rauc-pre-pre-handler is invoked by RAUC inside a separate
+		// mount namespace where the bundle file is mounted. Look at the mounted
+		// file through the magic symlink in /proc/[pid]/root.
+		unsquashfsFrom = filepath.Join("/proc", strconv.Itoa(ctx.InitialHandlerPID), "root", unsquashfsFrom)
+	}
+
+	if err := unsquashfs.Run(unsquashfsFrom, unsquashfsTo, &unsquashfs.Options{Force: true}, unsquashfsWhat); err != nil {
+		return err
+	}
+
+	// Rename the extracted boot directory to $boot/slot-$inactive.
+	renameFrom := filepath.Join(pi.bootMountPoint, squashfsRootDir, bootDir)
+	renameTo := filepath.Join(pi.bootMountPoint, slottedBootDir)
+
+	if err := os.Rename(renameFrom, renameTo); err != nil {
+		return err
+	}
+
+	// Remove the now-empty squashfs directory.
+	if err := os.RemoveAll(filepath.Join(pi.bootMountPoint, squashfsRootDir)); err != nil {
+		return err
+	}
+
+	// Load the boot configuration from the slot directory and modify root=.
+	bootCfgDir := filepath.Join(pi.bootMountPoint, slotPrefix(targetSlot))
+
+	slotBootCfg, err := NewBootConfig(bootCfgDir, pi.revCode, pi.serialNum)
+	if err != nil {
+		return err
+	}
+
+	// Set the root= argument to the block device matching the slot.
+	slotBootCfg.CmdLine.SetArg(argRoot, targetSlotInfo.DevicePath)
+	// Set the sysota.slot={A,B} kernel command line (informative only).
+	slotBootCfg.CmdLine.SetArg(argSysotaSlot, targetSlot.String())
+
+	// TODO(zyga): validate the final configuration.
+
+	// Save the configuration back to the per-slot directory.
+	return slotBootCfg.Save()
 }
 
 // PostInstallHandler reboots the Raspberry Pi if necessary.
@@ -354,7 +343,7 @@ func (pi *PiBoot) PostInstallHandler(ctx *installhandler.HandlerContext) error {
 // shouldReboot returns true if a slot with "system" class is being modified.
 func shouldReboot(ctx *installhandler.HandlerContext) bool {
 	for _, slotID := range ctx.TargetSlotIDs {
-		if ctx.Slots[slotID].Class == "system" {
+		if ctx.Slots[slotID].Class == systemClass {
 			return true
 		}
 	}
diff --git a/boot/piboot/piboot_test.go b/boot/piboot/piboot_test.go
index 49ba2cb..d301c5c 100644
--- a/boot/piboot/piboot_test.go
+++ b/boot/piboot/piboot_test.go
@@ -4,6 +4,7 @@
 package piboot_test
 
 import (
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -13,9 +14,9 @@ import (
 
 	"git.ostc-eu.org/OSTC/OHOS/components/sysota/boot"
 	"git.ostc-eu.org/OSTC/OHOS/components/sysota/boot/piboot"
-	"git.ostc-eu.org/OSTC/OHOS/components/sysota/picfg/condfilter"
 	"git.ostc-eu.org/OSTC/OHOS/components/sysota/picfg/pimodel"
 	"git.ostc-eu.org/OSTC/OHOS/components/sysota/rauc/installhandler"
+	"git.ostc-eu.org/OSTC/OHOS/components/sysota/squashfstools/mksquashfs"
 	"git.ostc-eu.org/OSTC/OHOS/components/sysota/testutil"
 )
 
@@ -26,6 +27,7 @@ type bootTest struct {
 	bootDir   string
 	revCode   pimodel.RevisionCode
 	serialNum pimodel.SerialNumber
+	pi        *piboot.PiBoot
 }
 
 var _ = Suite(&bootTest{
@@ -36,52 +38,53 @@ var _ = Suite(&bootTest{
 
 func (s *bootTest) SetUpTest(c *C) {
 	s.bootDir = c.MkDir()
-	s.MockConfigTxt(c, "")
+	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
+	c.Assert(err, IsNil)
+
+	s.pi = pi
 }
 
-func (s *bootTest) MockConfigTxt(c *C, text string) {
-	configName := filepath.Join(s.bootDir, "config.txt")
-	err := ioutil.WriteFile(configName, []byte(text), 0o644)
+func (s *bootTest) MockBootFile(c *C, path, content string) {
+	name := filepath.Join(s.bootDir, path)
+
+	err := os.MkdirAll(filepath.Dir(name), 0o755)
 	c.Assert(err, IsNil)
-}
 
-func (s *bootTest) MockTrybootTxt(c *C, text string) {
-	configName := filepath.Join(s.bootDir, "tryboot.txt")
-	err := ioutil.WriteFile(configName, []byte(text), 0o644)
+	err = ioutil.WriteFile(name, []byte(content), 0o644)
 	c.Assert(err, IsNil)
 }
 
-func (s *bootTest) TestOpen(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
-	c.Assert(err, IsNil)
-	c.Check(pi.BootMountPoint, Equals, s.bootDir)
+func (s *bootTest) MockConfigTxt(c *C, content string) {
+	s.MockBootFile(c, "config.txt", content)
 }
 
-func (s *bootTest) TestQueryActive(c *C) {
+func (s *bootTest) MockTrybootTxt(c *C, content string) {
+	s.MockBootFile(c, "tryboot.txt", content)
+}
+
+func (s *bootTest) TestNew(c *C) {
 	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
 	c.Assert(err, IsNil)
 
-	s.MockConfigTxt(c, ``)
+	c.Check(pi.BootMountPoint(), Equals, s.bootDir)
+	c.Check(pi.RevisionCode(), Equals, s.revCode)
+	c.Check(pi.SerialNumber(), Equals, s.serialNum)
+}
 
-	slot, err := pi.QueryActive()
-	c.Assert(err, ErrorMatches, "cannot determine active slot: inconsistent or corrupted configuration")
+func (s *bootTest) TestQueryActive(c *C) {
+	slot, err := s.pi.QueryActive()
+	c.Assert(err, ErrorMatches, "cannot determine active slot: incompatible boot configuration")
 	c.Check(slot, Equals, boot.InvalidSlot)
 
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-a/vmlinuz\n"+
-		"cmdline=slot-a/cmdline.txt\n"+
-		"initramfs slot-a/initrd.img followkernel\n")
+	s.MockConfigTxt(c, "os_prefix=slot-a/\n")
 
-	slot, err = pi.QueryActive()
+	slot, err = s.pi.QueryActive()
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, boot.SlotA)
 
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-b/vmlinuz\n"+
-		"cmdline=slot-b/cmdline.txt\n"+
-		"initramfs slot-b/initrd.img followkernel\n")
+	s.MockConfigTxt(c, "os_prefix=slot-b/\n")
 
-	slot, err = pi.QueryActive()
+	slot, err = s.pi.QueryActive()
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, boot.SlotB)
 
@@ -90,228 +93,145 @@ func (s *bootTest) TestQueryActive(c *C) {
 		"cmdline=slot-b/cmdline.txt\n"+
 		"initramfs slot-c/initrd.img followkernel\n")
 
-	slot, err = pi.QueryActive()
-	c.Assert(err, ErrorMatches, "cannot determine active slot: inconsistent or corrupted configuration")
+	slot, err = s.pi.QueryActive()
+	c.Assert(err, ErrorMatches, "cannot determine active slot: incompatible boot configuration")
 	c.Check(slot, Equals, boot.InvalidSlot)
 }
 
-func (s *bootTest) TestQueryActiveWithFilterPredicate(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
-	c.Assert(err, IsNil)
-
-	s.MockConfigTxt(c, ""+
-		"[pi3]\n"+
-		"kernel=slot-b/pi3-vmlinuz\n"+
-		"cmdline=slot-b/pi3-cmdline.txt\n"+
-		"initramfs slot-b/pi3-initrd.img followkernel\n"+
-		"[pi4]\n"+
-		"kernel=slot-a/pi4-vmlinuz\n"+
-		"cmdline=slot-a/pi4-cmdline.txt\n"+
-		"initramfs slot-a/pi4-initrd.img followkernel\n")
-
-	pi.FilterPredicate = func(effective []condfilter.ConditionalFilter) bool {
-		for _, filter := range effective {
-			if model, ok := filter.(condfilter.PiModelFilter); ok {
-				return model == condfilter.Pi4
-			}
-		}
-
-		return true
-	}
-	slot, err := pi.QueryActive()
-	c.Assert(err, IsNil)
-	c.Check(slot, Equals, boot.SlotA)
-
-	pi.FilterPredicate = func(effective []condfilter.ConditionalFilter) bool {
-		for _, filter := range effective {
-			if model, ok := filter.(condfilter.PiModelFilter); ok {
-				return model == condfilter.Pi3
-			}
-		}
-
-		return true
-	}
-	slot, err = pi.QueryActive()
-	c.Assert(err, IsNil)
-	c.Check(slot, Equals, boot.SlotB)
-}
-
 func (s *bootTest) TestQueryInactive(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
-	c.Assert(err, IsNil)
-
-	s.MockConfigTxt(c, ``)
-
-	slot, err := pi.QueryInactive()
-	c.Assert(err, ErrorMatches, "cannot determine inactive slot: inconsistent or corrupted configuration")
+	slot, err := s.pi.QueryInactive()
+	c.Assert(err, ErrorMatches, "cannot determine inactive slot: incompatible boot configuration")
 	c.Check(slot, Equals, boot.InvalidSlot)
 
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-a/vmlinuz\n"+
-		"cmdline=slot-a/cmdline.txt\n"+
-		"initramfs slot-a/initrd.img followkernel\n")
+	s.MockConfigTxt(c, "os_prefix=slot-a/")
 
-	slot, err = pi.QueryInactive()
+	slot, err = s.pi.QueryInactive()
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, boot.SlotB)
 
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-b/vmlinuz\n"+
-		"cmdline=slot-b/cmdline.txt\n"+
-		"initramfs slot-b/initrd.img followkernel\n")
+	s.MockConfigTxt(c, "os_prefix=slot-b/")
 
-	slot, err = pi.QueryInactive()
+	slot, err = s.pi.QueryInactive()
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, boot.SlotA)
 
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-a/vmlinuz\n"+
-		"cmdline=slot-b/cmdline.txt\n"+
-		"initramfs slot-c/initrd.img followkernel\n")
+	s.MockConfigTxt(c, "os_prefix=potato/")
 
-	slot, err = pi.QueryInactive()
-	c.Assert(err, ErrorMatches, "cannot determine inactive slot: inconsistent or corrupted configuration")
+	slot, err = s.pi.QueryInactive()
+	c.Assert(err, ErrorMatches, "cannot determine inactive slot: incompatible boot configuration")
 	c.Check(slot, Equals, boot.InvalidSlot)
 }
 
 func (s *bootTest) TestTrySwitchAtoB(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
-	c.Assert(err, IsNil)
-
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-a/vmlinuz\n"+
-		"cmdline=slot-a/cmdline.txt\n"+
-		"initramfs slot-a/initrd.img followkernel\n")
+	s.MockConfigTxt(c, "# pristine")
+	s.MockBootFile(c, "slot-b/config.txt", ""+
+		"kernel=vmlinuz\n"+
+		"cmdline=cmdline.txt\n"+
+		"initramfs initrd.img followkernel\n")
+	s.MockBootFile(c, "slot-b/cmdline.txt", "... root=/dev/mmcblk0p4 sysota.slot=B")
 
-	err = pi.TrySwitch(boot.SlotB)
+	err := s.pi.TrySwitch(boot.SlotB)
 	c.Assert(err, IsNil)
 
+	// The config.txt file is not touched.
 	data, err := ioutil.ReadFile(filepath.Join(s.bootDir, "config.txt"))
 	c.Assert(err, IsNil)
-	c.Check(string(data), Equals, ""+
-		"kernel=slot-a/vmlinuz\n"+
-		"cmdline=slot-a/cmdline.txt\n"+
-		"initramfs slot-a/initrd.img followkernel\n")
+	c.Check(string(data), Equals, "# pristine")
 
+	// The tryboot.txt is slot-b/config.txt with os_prefix= set.
 	data, err = ioutil.ReadFile(filepath.Join(s.bootDir, "tryboot.txt"))
 	c.Assert(err, IsNil)
 	c.Check(string(data), Equals, ""+
-		"kernel=slot-b/vmlinuz\n"+
-		"cmdline=slot-b/cmdline.txt\n"+
-		"initramfs slot-b/initrd.img followkernel\n")
-}
+		"kernel=vmlinuz\n"+
+		"cmdline=cmdline.txt\n"+
+		"initramfs initrd.img followkernel\n"+
+		"os_prefix=slot-b/\n")
 
-func (s *bootTest) TestTrySwitchBtoA(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
+	// The cmdline.txt file is not modified.
+	data, err = ioutil.ReadFile(filepath.Join(s.bootDir, "slot-b", "cmdline.txt"))
 	c.Assert(err, IsNil)
+	c.Check(string(data), Equals, "... root=/dev/mmcblk0p4 sysota.slot=B")
+}
 
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-b/vmlinuz\n"+
-		"cmdline=slot-b/cmdline.txt\n"+
-		"initramfs slot-b/initrd.img followkernel\n")
+func (s *bootTest) TestTrySwitchBtoA(c *C) {
+	s.MockConfigTxt(c, "# pristine")
+	s.MockBootFile(c, "slot-a/config.txt", ""+
+		"kernel=vmlinuz\n"+
+		"cmdline=cmdline.txt\n"+
+		"initramfs initrd.img followkernel\n")
+	s.MockBootFile(c, "slot-a/cmdline.txt", "... root=/dev/mmcblk0p3 sysota.slot=A")
 
-	err = pi.TrySwitch(boot.SlotA)
+	err := s.pi.TrySwitch(boot.SlotA)
 	c.Assert(err, IsNil)
 
+	// The config.txt file is not touched.
 	data, err := ioutil.ReadFile(filepath.Join(s.bootDir, "config.txt"))
 	c.Assert(err, IsNil)
-	c.Check(string(data), Equals, ""+
-		"kernel=slot-b/vmlinuz\n"+
-		"cmdline=slot-b/cmdline.txt\n"+
-		"initramfs slot-b/initrd.img followkernel\n")
+	c.Check(string(data), Equals, "# pristine")
 
+	// The tryboot.txt is slot-a/config.txt with os_prefix= set.
 	data, err = ioutil.ReadFile(filepath.Join(s.bootDir, "tryboot.txt"))
 	c.Assert(err, IsNil)
 	c.Check(string(data), Equals, ""+
-		"kernel=slot-a/vmlinuz\n"+
-		"cmdline=slot-a/cmdline.txt\n"+
-		"initramfs slot-a/initrd.img followkernel\n")
-}
+		"kernel=vmlinuz\n"+
+		"cmdline=cmdline.txt\n"+
+		"initramfs initrd.img followkernel\n"+
+		"os_prefix=slot-a/\n")
 
-func (s *bootTest) TestReboot(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
+	// The cmdline.txt file is not modified.
+	data, err = ioutil.ReadFile(filepath.Join(s.bootDir, "slot-a", "cmdline.txt"))
 	c.Assert(err, IsNil)
+	c.Check(string(data), Equals, "... root=/dev/mmcblk0p3 sysota.slot=A")
+}
 
+func (s *bootTest) TestReboot(c *C) {
 	restore, rebootLog := s.MockCommand(c, "reboot")
 	defer restore()
 
-	err = pi.Reboot(0)
+	err := s.pi.Reboot(0)
 	c.Assert(err, IsNil)
 	c.Check(rebootLog(c), DeepEquals, []string{`reboot`})
 
-	err = pi.Reboot(boot.RebootTryBoot)
+	err = s.pi.Reboot(boot.RebootTryBoot)
 	c.Assert(err, IsNil)
 	c.Check(rebootLog(c), DeepEquals, []string{`reboot 0\ tryboot`})
 }
 
 func (s *bootTest) TestCommitSwitch(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
-	c.Assert(err, IsNil)
+	s.MockConfigTxt(c, "os_prefix=slot-a/")
+	s.MockTrybootTxt(c, "os_prefix=slot-b/")
 
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-a/vmlinuz\n"+
-		"cmdline=slot-a/cmdline.txt\n"+
-		"initramfs slot-a/initrd.img followkernel\n")
-	s.MockTrybootTxt(c, ""+
-		"kernel=slot-b/vmlinuz\n"+
-		"cmdline=slot-b/cmdline.txt\n"+
-		"initramfs slot-b/initrd.img followkernel\n")
-
-	err = pi.CommitSwitch()
+	err := s.pi.CommitSwitch()
 	c.Assert(err, IsNil)
 
-	slot, err := pi.QueryActive()
+	slot, err := s.pi.QueryActive()
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, boot.SlotB)
 
-	_, err = os.Stat(filepath.Join(pi.BootMountPoint, "tryboot.txt"))
+	_, err = os.Stat(filepath.Join(s.bootDir, "tryboot.txt"))
 	c.Assert(os.IsNotExist(err), Equals, true)
 }
 
 func (s *bootTest) TestCancelSwitch(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
-	c.Assert(err, IsNil)
-
-	s.MockConfigTxt(c, ""+
-		"kernel=slot-a/vmlinuz\n"+
-		"cmdline=slot-a/cmdline.txt\n"+
-		"initramfs slot-a/initrd.img followkernel\n")
-	s.MockTrybootTxt(c, ""+
-		"kernel=slot-b/vmlinuz\n"+
-		"cmdline=slot-b/cmdline.txt\n"+
-		"initramfs slot-b/initrd.img followkernel\n")
+	s.MockConfigTxt(c, "os_prefix=slot-a/")
+	s.MockTrybootTxt(c, "os_prefix=slot-b/")
 
-	err = pi.CancelSwitch()
+	err := s.pi.CancelSwitch()
 	c.Assert(err, IsNil)
 
-	slot, err := pi.QueryActive()
+	slot, err := s.pi.QueryActive()
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, boot.SlotA)
 
-	_, err = os.Stat(filepath.Join(pi.BootMountPoint, "tryboot.txt"))
+	_, err = os.Stat(filepath.Join(s.pi.BootMountPoint(), "tryboot.txt"))
 	c.Assert(os.IsNotExist(err), Equals, true)
 
 	// Idempotent
-	err = pi.CancelSwitch()
+	err = s.pi.CancelSwitch()
 	c.Assert(err, IsNil)
 }
 
-func (s *bootTest) TestVisitSectionsCondfilterErrors(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
-	c.Assert(err, IsNil)
-
-	pi.FilterPredicate = func([]condfilter.ConditionalFilter) bool { return true }
-
-	s.MockConfigTxt(c, "[frozbonicator]")
-
-	_, err = pi.QueryActive()
-	c.Assert(err, ErrorMatches, `cannot determine active slot: conditional filter error "\[frozbonicator\]": unknown filter`)
-}
-
 func (s *bootTest) TestPostInstallHandler(c *C) {
-	pi, err := piboot.New(s.bootDir, s.revCode, s.serialNum)
-	c.Assert(err, IsNil)
-
 	restore, rebootLog := s.MockCommand(c, "reboot")
 	defer restore()
 
@@ -321,7 +241,7 @@ func (s *bootTest) TestPostInstallHandler(c *C) {
 		"RAUC_SLOT_CLASS_1=potato",
 	})
 	c.Check(err, IsNil)
-	err = pi.PostInstallHandler(ctx)
+	err = s.pi.PostInstallHandler(ctx)
 	c.Check(err, IsNil)
 	c.Check(rebootLog(c), DeepEquals, []string(nil))
 
@@ -331,7 +251,97 @@ func (s *bootTest) TestPostInstallHandler(c *C) {
 		"RAUC_SLOT_CLASS_1=system",
 	})
 	c.Assert(err, IsNil)
-	err = pi.PostInstallHandler(ctx)
+	err = s.pi.PostInstallHandler(ctx)
 	c.Check(err, IsNil)
 	c.Check(rebootLog(c), DeepEquals, []string{`reboot 0\ tryboot`})
 }
+
+type preInstallHandlerSuite struct {
+	bootDir   string
+	bundleDir string
+	systemDir string
+
+	revCode   pimodel.RevisionCode
+	serialNum pimodel.SerialNumber
+
+	pi  *piboot.PiBoot
+	ctx *installhandler.HandlerContext
+}
+
+var _ = Suite(&preInstallHandlerSuite{
+	// Arbitrary choice for as long a valid revision is used.
+	revCode:   Pi4BRevCode,
+	serialNum: sampleSerial,
+})
+
+func (s *preInstallHandlerSuite) SetUpTest(c *C) {
+	var err error
+
+	s.bootDir = c.MkDir()
+	s.bundleDir = c.MkDir()
+	s.systemDir = c.MkDir()
+
+	s.pi, err = piboot.New(s.bootDir, s.revCode, s.serialNum)
+	c.Assert(err, IsNil)
+
+	// Prepare minimum configuration of the current boot system.
+	// This is needed to detect updates which would clobber the current system.
+	c.Assert(ioutil.WriteFile(filepath.Join(s.bootDir, "config.txt"), []byte("os_prefix=slot-a/"), 0o600), IsNil)
+
+	// Prepare an fake system image.
+	c.Assert(os.Mkdir(filepath.Join(s.systemDir, "boot"), 0o700), IsNil)
+	c.Assert(ioutil.WriteFile(filepath.Join(s.systemDir, "boot/vmlinuz-5.10"), []byte("kernel"), 0o600), IsNil)
+	c.Assert(ioutil.WriteFile(filepath.Join(s.systemDir, "boot/initrd-5.10.img"), []byte("initrd"), 0o600), IsNil)
+	c.Assert(ioutil.WriteFile(filepath.Join(s.systemDir, "boot/config.txt"), []byte(""+
+		"kernel=vmlinuz-5.10\n"+
+		"initramfs initrd-5.10.img followkernel\n"), 0o600), IsNil)
+	c.Assert(ioutil.WriteFile(filepath.Join(s.systemDir, "boot/cmdline.txt"), []byte("root=hardcoded"), 0o600), IsNil)
+	c.Assert(mksquashfs.Run(filepath.Join(s.bundleDir, "system.img"), nil, s.systemDir), IsNil)
+
+	// Prepare the handler context
+	s.ctx, err = installhandler.NewHandlerContext([]string{
+		fmt.Sprintf("RAUC_BUNDLE_MOUNT_POINT=%s", s.bundleDir),
+		"RAUC_IMAGE_CLASS_1=system",
+		"RAUC_IMAGE_NAME_1=system.img",
+		"RAUC_SLOT_BOOTNAME_1=B",
+		"RAUC_SLOT_BOOTNAME_2=A",
+		"RAUC_SLOT_CLASS_1=system",
+		"RAUC_SLOT_CLASS_2=system",
+		"RAUC_SLOT_DEVICE_1=/dev/mmcblk0p4",
+		"RAUC_SLOT_DEVICE_2=/dev/mmcblk0p3",
+		"RAUC_SLOTS=1 2 ",
+		"RAUC_TARGET_SLOTS=1 ",
+	})
+	c.Assert(err, IsNil)
+}
+
+func (s *preInstallHandlerSuite) TestPreInstallHandler(c *C) {
+	// Run the pre-install handler.
+	err := s.pi.PreInstallHandler(s.ctx)
+	c.Assert(err, IsNil)
+
+	data, err := ioutil.ReadFile(filepath.Join(s.bootDir, "slot-b", "config.txt"))
+	c.Assert(err, IsNil)
+	c.Check(string(data), Equals, ""+
+		"kernel=vmlinuz-5.10\n"+
+		"initramfs initrd-5.10.img followkernel\n")
+
+	data, err = ioutil.ReadFile(filepath.Join(s.bootDir, "slot-b", "cmdline.txt"))
+	c.Assert(err, IsNil)
+	c.Check(string(data), Equals, "root=/dev/mmcblk0p4 sysota.slot=B")
+
+	data, err = ioutil.ReadFile(filepath.Join(s.bootDir, "slot-b", "vmlinuz-5.10"))
+	c.Assert(err, IsNil)
+	c.Check(string(data), Equals, "kernel")
+
+	data, err = ioutil.ReadFile(filepath.Join(s.bootDir, "slot-b", "initrd-5.10.img"))
+	c.Assert(err, IsNil)
+	c.Check(string(data), Equals, "initrd")
+}
+
+func (s *preInstallHandlerSuite) TestPreInstallHandlerSanityCheck(c *C) {
+	c.Assert(ioutil.WriteFile(filepath.Join(s.bootDir, "config.txt"), []byte("os_prefix=slot-b/"), 0o600), IsNil)
+
+	err := s.pi.PreInstallHandler(s.ctx)
+	c.Assert(err, ErrorMatches, `pre-install hook would clobber active slot`)
+}
diff --git a/boot/piboot/tests/bin/check-slot-a-b-pristine.sh b/boot/piboot/tests/bin/check-slot-a-b-pristine.sh
new file mode 100755
index 0000000..c8b4cf1
--- /dev/null
+++ b/boot/piboot/tests/bin/check-slot-a-b-pristine.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Huawei Inc.
+
+set -e
+set -x
+
+# Check that the slot-specific boot directories originally copied by the
+# pre-install handler are pristine. This data is consistent with what is
+# prepared by spread.yaml for the test suite boot/piboot/tests.
+
+test -e "$SYSOTA_BOOT_DIR"/slot-a/config.txt
+test -e "$SYSOTA_BOOT_DIR"/slot-a/cmdline.txt
+
+test -e "$SYSOTA_BOOT_DIR"/slot-b/config.txt
+test -e "$SYSOTA_BOOT_DIR"/slot-b/cmdline.txt
+
+# OS prefix is not set in either config file.
+NOMATCH 'os_prefix=' "$SYSOTA_BOOT_DIR"/slot-a/config.txt
+NOMATCH 'os_prefix=' "$SYSOTA_BOOT_DIR"/slot-b/config.txt
+
+# cmdline= is set to the data provided by RAUC.
+MATCH 'root=/tmp/rauc-fake-system/slot-a' "$SYSOTA_BOOT_DIR"/slot-a/cmdline.txt
+MATCH 'root=/tmp/rauc-fake-system/slot-b' "$SYSOTA_BOOT_DIR"/slot-b/cmdline.txt
diff --git a/boot/piboot/tests/bundle-install/task.yaml b/boot/piboot/tests/bundle-install/task.yaml
new file mode 100644
index 0000000..0d0b2d0
--- /dev/null
+++ b/boot/piboot/tests/bundle-install/task.yaml
@@ -0,0 +1,89 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Huawei Inc.
+
+summary: play-through of "rauc install"
+# This test doesn't run in LXD as RAUC cannot  mount a squashfs due to the
+# sandbox blocking that. It would only work if RAUC would be able to detect
+# and handle that situation and switch to FUSE. This test works fine in qemu.
+backends: [-lxd]
+prepare: |
+    mkdir bundle-dir
+    mkdir system-dir
+
+    # Create a fake system image, with an extremely barren /etc and /boot
+    # directories. As the validation logic in SysOTA improves, this may have to
+    # look more realistic, at least with regards to boot assets.
+    mkdir system-dir/etc
+    mkdir system-dir/boot
+
+    cat <<OS_RELEASE > system-dir/etc/os-release
+    ID=sysota-test
+    ID_VERSION=1
+    OS_RELEASE
+
+    cat <<CONFIG_TXT > system-dir/boot/config.txt
+    kernel=new-kernel.img
+    CONFIG_TXT
+
+    cat <<CMDLINE_TXT > system-dir/boot/cmdline.txt
+    new-cmdline-text root=hardcoded-root
+    CMDLINE_TXT
+
+    mksquashfs system-dir bundle-dir/system.img -comp zstd
+
+    # APPEND to the RAUC configuration file to set SystemOTA as custom
+    # bootloader handler, pre-install handler and post-install handler.
+    cat <<RAUC_SYSTEM_CONF >>/etc/rauc/system.conf
+    [handlers]
+    pre-install=/usr/libexec/sysota/rauc-pre-install-handler
+    post-install=/usr/libexec/sysota/rauc-post-install-handler
+    bootloader-custom-backend=/usr/libexec/sysota/rauc-custom-boot-handler
+    RAUC_SYSTEM_CONF
+
+    # Create a fake rauc bundle with that system image
+    cat <<RAUC_MANIFEST >bundle-dir/manifest.raucm
+    [update]
+    compatible=OSTC SystemOTA Test Environment
+    version=1
+
+    [image.system]
+    filename=system.img
+    RAUC_MANIFEST
+
+    rauc bundle --cert="$TEST_RAUC_KEYS_DIR/cert.pem" --key="$TEST_RAUC_KEYS_DIR/key.pem" bundle-dir bundle.img
+
+    # Store /proc/cmdline and allow us to fake /proc/cmdline easily in the execute phase below.
+    cp /proc/cmdline cmdline.orig
+    cp cmdline.orig cmdline
+    printf "%s rauc.slot=A\n" "$(cat cmdline.orig)" > cmdline
+    mount --bind cmdline /proc/cmdline
+
+    # Stop rauc and grab a cursor for the journal log
+    systemctl stop rauc.service || true
+    journalctl -u sysotad.service --cursor-file=cursor >/dev/null || true
+
+    # Truncate the log files log and reset backend state
+    truncate --size=0 /var/{backend,pre-install,post-install}.log
+    echo 0 >/tmp/backend.state
+
+execute: |
+    rauc install bundle.img
+
+    MATCH "os_prefix=slot-b/" "$SYSOTA_BOOT_DIR/tryboot.txt"
+    MATCH "kernel=new-kernel.img" "$SYSOTA_BOOT_DIR/tryboot.txt"
+    MATCH "new-cmdline-text root=/tmp/rauc-fake-system/slot-b sysota.slot=B" "$SYSOTA_BOOT_DIR/slot-b/cmdline.txt"
+
+    test -f "$MOCK_REBOOT_LOG"
+    MATCH '^/usr/sbin/reboot 0\\ tryboot$' "$MOCK_REBOOT_LOG"
+
+restore: |
+    umount /proc/cmdline || true
+
+    rm -rf system-dir bundle-dir
+    rm -f *.img
+    rm -f cursor
+    rm -f cmdline{,.orig}
+
+debug: |
+    cat /proc/cmdline
+    journalctl --cursor-file=cursor -u rauc.service
diff --git a/boot/piboot/tests/cancel-switch/task.yaml b/boot/piboot/tests/cancel-switch/task.yaml
index 74423f2..173bcd7 100644
--- a/boot/piboot/tests/cancel-switch/task.yaml
+++ b/boot/piboot/tests/cancel-switch/task.yaml
@@ -5,16 +5,21 @@ summary: Integration tests for the PiBoot.CancelSwitch function
 details: |
     The CancelSwitch function removes the tryboot.txt file.
 prepare: |
-    cat <<CONFIG_TXT >"$SYSOTA_BOOT_DIR"/tryboot.txt
-    kernel=slot-b/kernel
-    cmdline=slot-b/cmdline.txt
-    initramfs slot-b/initrd.cpio
-    CONFIG_TXT
+    # Note that this test depends on the extensive boot configuration
+    # done at the suite prepare stage.
+
+    # Prepare for the switch to slot B. This is tested separately in another test.
+    test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader TrySwitch s "B")" = ""
 execute: |
     test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader CancelSwitch)" = ""
+
+    # The inactive slot configuration files were removed.
     test ! -e "$SYSOTA_BOOT_DIR"/tryboot.txt
+
+    # The active slot configuration files are pristine.
     test -e "$SYSOTA_BOOT_DIR"/config.txt
-    MATCH 'slot-a/' "$SYSOTA_BOOT_DIR"/config.txt
-restore: |
-    test ! -e "$SYSOTA_BOOT_DIR"/tryboot.txe
-    rm -f "$SYSOTA_BOOT_DIR"/tryboot.txt
+    MATCH 'os_prefix=slot-a/' "$SYSOTA_BOOT_DIR"/config.txt
+
+    # The slot-specific boot directories originally prepared by the pre-install
+    # handler are pristine.
+    check-slot-a-b-pristine.sh
diff --git a/boot/piboot/tests/commit-switch/task.yaml b/boot/piboot/tests/commit-switch/task.yaml
index c3accdf..ab58344 100644
--- a/boot/piboot/tests/commit-switch/task.yaml
+++ b/boot/piboot/tests/commit-switch/task.yaml
@@ -6,13 +6,21 @@ details: |
     The CommitSwitch function renames the tryboot.txt file to config.txt, thus
     cementing the single-boot configuration for subsequent reboots.
 prepare: |
-    cat <<CONFIG_TXT >"$SYSOTA_BOOT_DIR"/tryboot.txt
-    kernel=slot-b/kernel
-    cmdline=slot-b/cmdline.txt
-    initramfs slot-b/initrd.cpio
-    CONFIG_TXT
+    # Note that this test depends on the extensive boot configuration
+    # done at the suite prepare stage.
+
+    # Prepare for the switch to slot B. This is tested separately in another test.
+    test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader TrySwitch s "B")" = ""
 execute: |
     test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader CommitSwitch)" = ""
+
+    # The tryboot.txt file is now gone.
     test ! -e "$SYSOTA_BOOT_DIR"/tryboot.txt
+
+    # The config.txt now points to slot-b and a dedicated cmdline file.
     test -e "$SYSOTA_BOOT_DIR"/config.txt
-    MATCH 'slot-b/' "$SYSOTA_BOOT_DIR"/config.txt
+    MATCH 'os_prefix=slot-b/' "$SYSOTA_BOOT_DIR"/config.txt
+
+    # The slot-specific boot directories originally prepared by the pre-install
+    # handler are pristine.
+    check-slot-a-b-pristine.sh
diff --git a/boot/piboot/tests/smoke/task.yaml b/boot/piboot/tests/smoke/task.yaml
deleted file mode 100644
index f37c9a1..0000000
--- a/boot/piboot/tests/smoke/task.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Huawei Inc.
-
-summary: Smoke tests for the boot protocol for the Raspberry Pi
-execute: |
-    # Given the initial state, we see the A and B slots as active and inactive respectively
-    test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader QueryActive)" = 's "A"'
-    test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader QueryInactive)" = 's "B"'
-
-    # The TrySwitch function creates a tryboot.txt file, replacing slot A with slot B without affecting config.txt
-    test ! -e "$SYSOTA_BOOT_DIR"/tryboot.txt
-    test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader TrySwitch s "B")" = ""
-    MATCH 'slot-a/' "$SYSOTA_BOOT_DIR"/config.txt
-    MATCH 'slot-b/' "$SYSOTA_BOOT_DIR"/tryboot.txt
-
-    # The Reboot function invoked with the try-boot flag reboots the system,
-    # passing special argument picked up by the Raspberry Pi kernel.
-    test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader Reboot u 1)" = ""
-    test -f "$MOCK_REBOOT_LOG"
-    MATCH 'reboot 0\\ tryboot' "$MOCK_REBOOT_LOG"
-
-    # The CommitSwitch function replaces config.txt with tryboot.txt
-    test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader CommitSwitch)" = ""
-    test ! -e "$SYSOTA_BOOT_DIR"/tryboot.txt
-    test -e "$SYSOTA_BOOT_DIR"/config.txt
-    MATCH 'slot-b/' "$SYSOTA_BOOT_DIR"/config.txt
-restore: |
-    rm -f "$SYSOTA_BOOT_DIR"/tryboot.txt
diff --git a/boot/piboot/tests/try-switch/task.yaml b/boot/piboot/tests/try-switch/task.yaml
index f627551..897e15a 100644
--- a/boot/piboot/tests/try-switch/task.yaml
+++ b/boot/piboot/tests/try-switch/task.yaml
@@ -6,10 +6,24 @@ details: |
     The TrySwitch function creates a tryboot.txt file, replacing slot A with
     slot B without affecting config.txt. The contents of tryboot.txt is based on
     the contents of config.txt.
-execute: |
+prepare: |
+    # Note that this test depends on the extensive boot configuration
+    # done at the suite prepare stage.
+
+    # There is no configuration for the inactive slot prior to our call.
     test ! -e "$SYSOTA_BOOT_DIR"/tryboot.txt
+execute: |
     test "$(busctl call dev.ostc.sysota1 /dev/ostc/sysota1/Service dev.ostc.sysota1.BootLoader TrySwitch s "B")" = ""
-    MATCH 'slot-a/' "$SYSOTA_BOOT_DIR"/config.txt
-    MATCH 'slot-b/' "$SYSOTA_BOOT_DIR"/tryboot.txt
-restore: |
-    rm -f "$SYSOTA_BOOT_DIR"/tryboot.txt
+    MATCH 'os_prefix=slot-a/' "$SYSOTA_BOOT_DIR"/config.txt
+
+    # The active slot configuration files are pristine.
+    test -e "$SYSOTA_BOOT_DIR"/config.txt
+    MATCH 'os_prefix=slot-a/' "$SYSOTA_BOOT_DIR"/config.txt
+
+    # The inactive slot files are configured properly.
+    test -f "$SYSOTA_BOOT_DIR/tryboot.txt"
+    MATCH 'os_prefix=slot-b/' "$SYSOTA_BOOT_DIR"/tryboot.txt
+
+    # The slot-specific boot directories originally prepared by the pre-install
+    # handler are pristine.
+    check-slot-a-b-pristine.sh
diff --git a/cspell.json b/cspell.json
index 9a67566..5d5f062 100644
--- a/cspell.json
+++ b/cspell.json
@@ -53,6 +53,7 @@
         "fdatasync", // file data synchronization system call
         "FDCWD", // file descriptor of current working directory
         "findstring", // find string, a make function
+        "followkernel", // option specific to Raspberry Pi config.txt initramfs parameter
         "freedesktop", // free desktop organization
         "froz", // one of meta variable names
         "frozbonicator", // one of meta variable names
diff --git a/spread.yaml b/spread.yaml
index eff522f..044a075 100644
--- a/spread.yaml
+++ b/spread.yaml
@@ -103,6 +103,7 @@ suites:
             # Note: the variable is not special to sysotad, it is only used in tests.
             SYSOTA_BOOT_DIR: /var/tmp/sysota-fake/boot
             MOCK_REBOOT_LOG: /var/tmp/reboot.log
+            PATH: $PATH:$SPREAD_PATH/tests/bin:$SPREAD_PATH/boot/piboot/tests/bin
         prepare: |
             # Prepare sysotad.conf file that forces the use of pi-boot and sets
             # the location of the boot directory to SYSOTA_BOOT_DIR. Note that
@@ -125,28 +126,40 @@ suites:
             # In prepare-each we restore this state from a helper tarball.
 
             mkdir -p "$SYSOTA_BOOT_DIR"
-            mkdir -p "$SYSOTA_BOOT_DIR/slot-a/"
+            mkdir -p "$SYSOTA_BOOT_DIR"/slot-a
+            mkdir -p "$SYSOTA_BOOT_DIR"/slot-b
 
-            cat <<CONFIG_TXT >"$SYSOTA_BOOT_DIR"/slot-a/config.txt
-            # The fake config.txt as plausibly copied by pre-install handler and as
-            # present in the system image.
+            # Data for the slot A (presumed active).
+
+            cat <<SLOT_A_CONFIG_TXT >"$SYSOTA_BOOT_DIR"/slot-a/config.txt
             kernel=kernel.img
-            cmdline=cmdline.txt
-            initramfs initrd.cpio
-            CONFIG_TXT
+            SLOT_A_CONFIG_TXT
+
+            cat <<SLOT_A_CMDLINE_TXT >"$SYSOTA_BOOT_DIR"/slot-a/cmdline.txt
+            cmdline-text root=$TEST_RAUC_FAKE_SYSTEM_DIR/slot-a sysota.slot=A
+            SLOT_A_CMDLINE_TXT
 
-            cat <<CMDLINE_TXT >"$SYSOTA_BOOT_DIR"/slot-a/cmdline.txt
-            fake-unused-cmdline
-            CMDLINE_TXT
+            # Data for the slot B.
+
+            cat <<SLOT_B_CONFIG_TXT >"$SYSOTA_BOOT_DIR"/slot-b/config.txt
+            kernel=kernel.img
+            SLOT_B_CONFIG_TXT
+
+            cat <<SLOT_B_CMDLINE_TXT >"$SYSOTA_BOOT_DIR"/slot-b/cmdline.txt
+            cmdline-text root=$TEST_RAUC_FAKE_SYSTEM_DIR/slot-b sysota.slot=B
+            SLOT_B_CMDLINE_TXT
+
+            # Boot configuration for the active slot. This configuration differs
+            # from that of slot-a/config.txt by the presence of os_prefix=.
 
-            # The config.txt file as derived from slot-a/config.txt with slot-a/
-            # prefix added to kernel=, cmdline= and initramfs parameters.
             cat <<CONFIG_TXT >"$SYSOTA_BOOT_DIR"/config.txt
-            kernel=slot-a/kernel.img
-            cmdline=slot-a/cmdline.txt
-            initramfs slot-a/initrd.cpio
+            kernel=kernel.img
+            os_prefix=slot-a/
             CONFIG_TXT
 
+            # Note that the vanilla data does not define tryboot.txt. This is
+            # done in individual tests which require it.
+
             tar -zcf /var/tmp/vanilla-boot.tar.gz -C "$SYSOTA_BOOT_DIR" .
 
             # Restart sysotad.service at least once to make sure it load the new
@@ -204,7 +217,6 @@ suites:
 
         restore-each: |
             rm -f "$MOCK_REBOOT_LOG"
-            test ! -e "$SYSOTA_BOOT_DIR"/tryboot.txt
 
             # Restore bootloader configuration to vanilla state.
             rm -rf "$SYSOTA_BOOT_DIR"
-- 
GitLab