diff --git a/Makefile b/Makefile index 591d64b4..bc9c111d 100644 --- a/Makefile +++ b/Makefile @@ -13,11 +13,22 @@ DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/ GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp -.PHONY: help check-root builder-image rebuild-image check-image shell app support all clean +.PHONY: help check-root builder-image rebuild-image check-image shell app support all test test-s01fs-data-disk test-go-storage test-go-vm clean # Default target all: app support +test: test-s01fs-data-disk test-go-storage test-go-vm + +test-s01fs-data-disk: + @bash tools/test-s01fs-data-disk.sh + +test-go-storage: + @cd server && go test ./service/storage + +test-go-vm: + @cd server && go test ./service/vm/virtualdisk + # Help target help: @echo "NanoKVM Build System" @@ -31,6 +42,7 @@ help: @echo " app - Build Go application server" @echo " support - Build hardware support libraries" @echo " all - Build both app and support (default)" + @echo " test - Run repository tests" @echo " clean - Clean build artifacts" @echo "" @echo "Prerequisites:" @@ -94,4 +106,4 @@ clean: rm -rf support/sg2002/build; \ echo "Removed support/sg2002/build"; \ fi - @echo "Clean completed." \ No newline at end of file + @echo "Clean completed." diff --git a/kvmapp/system/init.d/S01fs b/kvmapp/system/init.d/S01fs index 3391d664..820ff080 100755 --- a/kvmapp/system/init.d/S01fs +++ b/kvmapp/system/init.d/S01fs @@ -1,41 +1,142 @@ #!/bin/sh +: "${NANOKVM_DISK:=/dev/mmcblk0}" +: "${NANOKVM_BOOT_PART:=/dev/mmcblk0p1}" +: "${NANOKVM_ROOT_PART:=/dev/mmcblk0p2}" +: "${NANOKVM_DATA_PART:=/dev/mmcblk0p3}" +: "${NANOKVM_BOOT_DIR:=/boot}" +: "${NANOKVM_DATA_DIR:=/data}" +: "${NANOKVM_DISK0_MARKER:=/etc/kvm.disk0}" +: "${NANOKVM_DATA_FORMAT_PENDING:=/etc/kvm.disk0.formatting}" +: "${NANOKVM_PROFILE:=/etc/profile}" + +is_empty_partition() ( + tmp="$(mktemp "${TMPDIR:-/tmp}/s01fs-data-head.XXXXXX")" || return 1 + trap 'rm -f "$tmp"' EXIT + + # Conservative recovery for devices stranded by older boots that marked + # disk0 complete before formatting finished. + if ! dd if="$1" of="$tmp" bs=1M count=4 2>/dev/null + then + return 1 + fi + + if [ "$(wc -c < "$tmp")" = "0" ] + then + return 0 + fi + + if ! od -An -tx1 "$tmp" | grep -q '[1-9a-fA-F]' + then + return 0 + fi + + return 1 +) + +format_and_mount_data_partition() { + if mkfs.exfat "$NANOKVM_DATA_PART" + then + rm -f "$NANOKVM_DATA_FORMAT_PENDING" + mkdir -p "$NANOKVM_DATA_DIR" + if mount "$NANOKVM_DATA_PART" "$NANOKVM_DATA_DIR" + then + touch "$NANOKVM_DISK0_MARKER" + else + rm -f "$NANOKVM_DISK0_MARKER" + fi + else + rm -f "$NANOKVM_DISK0_MARKER" + fi +} + +normalize_disk_file() { + printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' +} + if [ "$1" = "start" ] then + data_mount_handled=0 + data_marker_after_mount=0 + # use all sdcard free space for data - parted -s /dev/mmcblk0 "resizepart 2 -0" + parted -s "$NANOKVM_DISK" "resizepart 2 -0" echo "yes 8192MB - " | parted ---pretend-input-tty /dev/mmcblk0 "resizepart 2 8192MB" + " | parted ---pretend-input-tty "$NANOKVM_DISK" "resizepart 2 8192MB" # resize data filesystem - (resize2fs /dev/mmcblk0p2) & + (resize2fs "$NANOKVM_ROOT_PART") & - . /etc/profile + if [ -r "$NANOKVM_PROFILE" ] + then + # shellcheck disable=SC1090 + . "$NANOKVM_PROFILE" + fi printf "mounting filesystem : " - mkdir -p /boot - mount -t vfat /dev/mmcblk0p1 /boot + mkdir -p "$NANOKVM_BOOT_DIR" + mount -t vfat "$NANOKVM_BOOT_PART" "$NANOKVM_BOOT_DIR" mount -t configfs configfs /sys/kernel/config mount -t debugfs debugfs /sys/kernel/debug - if [ -e /boot/usb.disk0 ] + disk0_file="" + if [ -e "$NANOKVM_BOOT_DIR/usb.disk0" ] + then + disk0_file="$(normalize_disk_file "$(cat "$NANOKVM_BOOT_DIR/usb.disk0")")" + fi + + if [ -e "$NANOKVM_BOOT_DIR/usb.disk0" ] && { [ -z "$disk0_file" ] || [ "$disk0_file" = "$NANOKVM_DATA_PART" ]; } then - if [ ! -e /etc/kvm.disk0 ] + if [ ! -e "$NANOKVM_DATA_PART" ] then - touch /etc/kvm.disk0 # use all sdcard free space for data - parted -s /dev/mmcblk0 "mkpart primary 8193MB 100%" - sleep 1 - # resize data filesystem - (mkfs.exfat /dev/mmcblk0p3) & + if parted -s "$NANOKVM_DISK" "mkpart primary 8193MB 100%" + then + touch "$NANOKVM_DATA_FORMAT_PENDING" + fi sleep 1 fi + + fi + + if [ -e "$NANOKVM_DATA_PART" ] && [ -e "$NANOKVM_DATA_FORMAT_PENDING" ] && [ "$data_mount_handled" = "0" ] + then + format_and_mount_data_partition + data_mount_handled=1 fi - if [ -e /dev/mmcblk0p3 ] + if [ -e "$NANOKVM_DATA_PART" ] && [ "$data_mount_handled" = "0" ] then - mkdir -p /data - mount /dev/mmcblk0p3 /data + if [ -n "$(blkid "$NANOKVM_DATA_PART" 2>/dev/null)" ] + then + data_marker_after_mount=1 + elif [ -e "$NANOKVM_DISK0_MARKER" ] && is_empty_partition "$NANOKVM_DATA_PART" + then + touch "$NANOKVM_DATA_FORMAT_PENDING" + format_and_mount_data_partition + data_mount_handled=1 + else + # Existing unrecognized partitions may contain data. Only + # format partitions this script created, marked pending, or + # legacy-marked as complete while still empty. + rm -f "$NANOKVM_DISK0_MARKER" + data_mount_handled=1 + fi + fi + + if [ -e "$NANOKVM_DATA_PART" ] && [ "$data_mount_handled" = "0" ] + then + mkdir -p "$NANOKVM_DATA_DIR" + if mount "$NANOKVM_DATA_PART" "$NANOKVM_DATA_DIR" + then + if [ "$data_marker_after_mount" = "1" ] + then + touch "$NANOKVM_DISK0_MARKER" + fi + elif [ "$data_marker_after_mount" = "1" ] + then + rm -f "$NANOKVM_DISK0_MARKER" + fi fi echo "OK" diff --git a/kvmapp/system/init.d/S03usbdev b/kvmapp/system/init.d/S03usbdev index e4a2c834..be81a973 100755 --- a/kvmapp/system/init.d/S03usbdev +++ b/kvmapp/system/init.d/S03usbdev @@ -1,6 +1,62 @@ #!/bin/sh +# shellcheck disable=SC1091,SC2012,SC2164,SC3037 # kvmhwd Rev2.2 +: "${NANOKVM_DISK0_MARKER:=/etc/kvm.disk0}" +: "${NANOKVM_DATA_FORMAT_PENDING:=/etc/kvm.disk0.formatting}" +: "${NANOKVM_DATA_PART:=/dev/mmcblk0p3}" +: "${NANOKVM_BOOT_DIR:=/boot}" + +data_disk_ready(){ + [ -e "$NANOKVM_DISK0_MARKER" ] && [ ! -e "$NANOKVM_DATA_FORMAT_PENDING" ] && [ -e "$NANOKVM_DATA_PART" ] +} + +default_data_disk_file(){ + if data_disk_ready + then + printf '%s\n' "$NANOKVM_DATA_PART" + fi +} + +normalize_disk_file(){ + printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' +} + +resolve_disk_file(){ + disk_file="$(normalize_disk_file "$1")" + if [ -z "$disk_file" ] || [ "$disk_file" = "$NANOKVM_DATA_PART" ] + then + default_data_disk_file + else + printf '%s\n' "$disk_file" + fi +} + +configure_mass_storage(){ + if [ ! -e "$NANOKVM_BOOT_DIR/usb.disk0" ] + then + return + fi + + disk=$(cat "$NANOKVM_BOOT_DIR/usb.disk0") + data_file="$(resolve_disk_file "$disk")" + if [ -z "$data_file" ] + then + return + fi + + mkdir functions/mass_storage.disk0 + ln -s functions/mass_storage.disk0 configs/c.1/ + echo 1 > functions/mass_storage.disk0/lun.0/removable + if [ -e "$NANOKVM_BOOT_DIR/usb.disk0.ro" ] + then + echo 1 > functions/mass_storage.disk0/lun.0/ro + echo 0 > functions/mass_storage.disk0/lun.0/cdrom + fi + echo "NanoKVM USB Mass Storage0520" > functions/mass_storage.disk0/lun.0/inquiry_string + printf '%s\n' "$data_file" > functions/mass_storage.disk0/lun.0/file +} + start_usb_dev(){ . /etc/profile echo "usb mode: device" @@ -114,30 +170,7 @@ start_usb_dev(){ ln -s functions/hid.GS2 configs/c.1 fi - if [ -e /boot/usb.disk0 ] - then - mkdir functions/mass_storage.disk0 - ln -s functions/mass_storage.disk0 configs/c.1/ - echo 1 > functions/mass_storage.disk0/lun.0/removable - if [ -e /boot/usb.disk0.ro ] - then - echo 1 > functions/mass_storage.disk0/lun.0/ro - echo 0 > functions/mass_storage.disk0/lun.0/cdrom - fi - echo "NanoKVM USB Mass Storage0520" > functions/mass_storage.disk0/lun.0/inquiry_string - disk=$(cat /boot/usb.disk0) - if [ -z "${disk}" ] - then - # if [ ! -e /mnt/usbdisk.img ] - # then - # fallocate -l 8G /mnt/usbdisk.img - # mkfs.vfat /mnt/usbdisk.img - # fi - echo /dev/mmcblk0p3 > functions/mass_storage.disk0/lun.0/file - else - cat /boot/usb.disk0 > functions/mass_storage.disk0/lun.0/file - fi - fi + configure_mass_storage ls /sys/class/udc/ | cat > UDC echo device > /proc/cviusb/otg_role diff --git a/server/service/storage/image.go b/server/service/storage/image.go index 4e0be465..67e65504 100644 --- a/server/service/storage/image.go +++ b/server/service/storage/image.go @@ -18,13 +18,47 @@ import ( const ( imageDirectory = "/data" - imageNone = "/dev/mmcblk0p3" - cdromFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/cdrom" - mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file" - inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string" - roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro" + dataDiskMarker = "/etc/kvm.disk0" + formatPending = "/etc/kvm.disk0.formatting" + dataPartition = "/dev/mmcblk0p3" ) +var ( + imageNone = dataPartition + cdromFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/cdrom" + mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file" + inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string" + roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro" + + dataDiskMarkerPath = dataDiskMarker + formatPendingPath = formatPending + dataPartitionPath = dataPartition +) + +func defaultDataDiskImage() string { + if _, err := os.Stat(dataDiskMarkerPath); err != nil { + return "" + } + if _, err := os.Stat(formatPendingPath); err == nil { + return "" + } + if _, err := os.Stat(dataPartitionPath); err != nil { + return "" + } + return dataPartitionPath +} + +func mountImagePath(requested string) (string, bool) { + requested = strings.TrimSpace(requested) + if requested == "" { + return "", true + } + if requested == dataPartitionPath { + return requested, defaultDataDiskImage() != "" + } + return requested, true +} + func (s *Service) GetImages(c *gin.Context) { var rsp proto.Response var images []string @@ -63,6 +97,13 @@ func (s *Service) MountImage(c *gin.Context) { return } + // mount + image, ok := mountImagePath(req.File) + if !ok { + rsp.ErrRsp(c, -2, "data disk is not ready") + return + } + // cdrom and ro flag // set to 0 when unmount image // set to 1 when mount image and the CD-ROM is enabled @@ -108,12 +149,6 @@ func (s *Service) MountImage(c *gin.Context) { return } - // mount - image := req.File - if image == "" { - image = imageNone - } - if err := os.WriteFile(mountDevice, []byte(image), 0o666); err != nil { log.Errorf("mount file %s failed: %s", image, err) rsp.ErrRsp(c, -2, "mount image failed") diff --git a/server/service/storage/image_test.go b/server/service/storage/image_test.go new file mode 100644 index 00000000..c725df81 --- /dev/null +++ b/server/service/storage/image_test.go @@ -0,0 +1,301 @@ +package storage + +import ( + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func withDataDiskPaths(t *testing.T) (marker string, pending string, part string) { + t.Helper() + + oldMarker := dataDiskMarkerPath + oldPending := formatPendingPath + oldPart := dataPartitionPath + oldImageNone := imageNone + + dir := t.TempDir() + marker = filepath.Join(dir, "kvm.disk0") + pending = filepath.Join(dir, "kvm.disk0.formatting") + part = filepath.Join(dir, "mmcblk0p3") + + dataDiskMarkerPath = marker + formatPendingPath = pending + dataPartitionPath = part + imageNone = part + + t.Cleanup(func() { + dataDiskMarkerPath = oldMarker + formatPendingPath = oldPending + dataPartitionPath = oldPart + imageNone = oldImageNone + }) + + return marker, pending, part +} + +func touch(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("touch %s: %v", path, err) + } +} + +func TestMountImagePathDefaultDataDisk(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, marker, pending, part string) + requested string + wantImage string + wantOK bool + }{ + { + name: "empty request clears backing without data disk readiness", + wantImage: "", + wantOK: true, + }, + { + name: "explicit data partition allowed when ready", + setup: func(t *testing.T, marker, _pending, part string) { + touch(t, marker) + touch(t, part) + }, + requested: "DATA_PART", + wantImage: "DATA_PART", + wantOK: true, + }, + { + name: "explicit data partition with whitespace allowed when ready", + setup: func(t *testing.T, marker, _pending, part string) { + touch(t, marker) + touch(t, part) + }, + requested: " WHITESPACE_DATA_PART ", + wantImage: "DATA_PART", + wantOK: true, + }, + { + name: "explicit data partition blocked without ready marker", + setup: func(t *testing.T, _marker, _pending, part string) { + touch(t, part) + }, + requested: "DATA_PART", + wantOK: false, + }, + { + name: "explicit data partition with whitespace blocked without ready marker", + setup: func(t *testing.T, _marker, _pending, part string) { + touch(t, part) + }, + requested: " WHITESPACE_DATA_PART ", + wantOK: false, + }, + { + name: "explicit data partition blocked while format pending", + setup: func(t *testing.T, marker, pending, part string) { + touch(t, marker) + touch(t, pending) + touch(t, part) + }, + requested: "DATA_PART", + wantOK: false, + }, + { + name: "explicit data partition with whitespace blocked while format pending", + setup: func(t *testing.T, marker, pending, part string) { + touch(t, marker) + touch(t, pending) + touch(t, part) + }, + requested: " WHITESPACE_DATA_PART ", + wantOK: false, + }, + { + name: "explicit data partition blocked when partition missing", + setup: func(t *testing.T, marker, _pending, _part string) { + touch(t, marker) + }, + requested: "DATA_PART", + wantOK: false, + }, + { + name: "custom image allowed without data disk readiness", + requested: "/data/custom.iso", + wantImage: "/data/custom.iso", + wantOK: true, + }, + { + name: "custom image path is trimmed", + requested: " /data/custom.iso ", + wantImage: "/data/custom.iso", + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + marker, pending, part := withDataDiskPaths(t) + if tt.setup != nil { + tt.setup(t, marker, pending, part) + } + + requested := tt.requested + if requested == "DATA_PART" { + requested = part + } + if requested == " WHITESPACE_DATA_PART " { + requested = " " + part + " " + } + + got, ok := mountImagePath(requested) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + + want := tt.wantImage + if want == "DATA_PART" { + want = part + } + if got != want { + t.Fatalf("image = %q, want %q", got, want) + } + }) + } +} + +func TestMountImageDefaultDataDiskNotReadyHasNoSideEffects(t *testing.T) { + gin.SetMode(gin.TestMode) + withDataDiskPaths(t) + + oldMountDevice := mountDevice + oldRoFlag := roFlag + oldCdromFlag := cdromFlag + oldInquiryString := inquiryString + + dir := t.TempDir() + mountDevice = filepath.Join(dir, "file") + roFlag = filepath.Join(dir, "ro") + cdromFlag = filepath.Join(dir, "cdrom") + inquiryString = filepath.Join(dir, "inquiry") + + t.Cleanup(func() { + mountDevice = oldMountDevice + roFlag = oldRoFlag + cdromFlag = oldCdromFlag + inquiryString = oldInquiryString + }) + + writeFile := func(path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + readFile := func(path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(content) + } + + writeFile(mountDevice, "/data/current.iso") + writeFile(roFlag, "1") + writeFile(cdromFlag, "1") + writeFile(inquiryString, "old inquiry") + + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + context.Request = httptest.NewRequest("POST", "/storage/image/mount", strings.NewReader(fmt.Sprintf(`{"file":%q}`, dataPartitionPath))) + context.Request.Header.Set("Content-Type", "application/json") + + NewService().MountImage(context) + + if !strings.Contains(response.Body.String(), "data disk is not ready") { + t.Fatalf("response = %s, want data disk readiness error", response.Body.String()) + } + if got := readFile(mountDevice); got != "/data/current.iso" { + t.Fatalf("mountDevice = %q, want unchanged current image", got) + } + if got := readFile(roFlag); got != "1" { + t.Fatalf("roFlag = %q, want unchanged", got) + } + if got := readFile(cdromFlag); got != "1" { + t.Fatalf("cdromFlag = %q, want unchanged", got) + } + if got := readFile(inquiryString); got != "old inquiry" { + t.Fatalf("inquiryString = %q, want unchanged", got) + } +} + +func TestMountImageEmptyRequestClearsCurrentImageWhenDataDiskNotReady(t *testing.T) { + gin.SetMode(gin.TestMode) + withDataDiskPaths(t) + + oldMountDevice := mountDevice + oldRoFlag := roFlag + oldCdromFlag := cdromFlag + oldInquiryString := inquiryString + + dir := t.TempDir() + mountDevice = filepath.Join(dir, "file") + roFlag = filepath.Join(dir, "ro") + cdromFlag = filepath.Join(dir, "cdrom") + inquiryString = filepath.Join(dir, "inquiry") + + t.Cleanup(func() { + mountDevice = oldMountDevice + roFlag = oldRoFlag + cdromFlag = oldCdromFlag + inquiryString = oldInquiryString + }) + + writeFile := func(path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + readFile := func(path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(content) + } + + writeFile(mountDevice, "/data/current.iso") + writeFile(roFlag, "1") + writeFile(cdromFlag, "1") + writeFile(inquiryString, "old inquiry") + + response := httptest.NewRecorder() + context, _ := gin.CreateTestContext(response) + context.Request = httptest.NewRequest("POST", "/storage/image/mount", strings.NewReader(`{"file":""}`)) + context.Request.Header.Set("Content-Type", "application/json") + + NewService().MountImage(context) + + if strings.Contains(response.Body.String(), "data disk is not ready") { + t.Fatalf("response = %s, should allow empty unmount request", response.Body.String()) + } + if got := readFile(mountDevice); got != "" { + t.Fatalf("mountDevice = %q, want cleared backing file", got) + } + if got := readFile(roFlag); got != "0" { + t.Fatalf("roFlag = %q, want reset", got) + } + if got := readFile(cdromFlag); got != "0" { + t.Fatalf("cdromFlag = %q, want reset", got) + } +} diff --git a/server/service/vm/virtual-device.go b/server/service/vm/virtual-device.go index 2eb40e98..13872d9e 100644 --- a/server/service/vm/virtual-device.go +++ b/server/service/vm/virtual-device.go @@ -10,11 +10,22 @@ import ( "NanoKVM-Server/proto" "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/vm/virtualdisk" ) const ( virtualNetwork = "/boot/usb.rndis0" virtualDisk = "/boot/usb.disk0" + dataDiskMarker = "/etc/kvm.disk0" + formatPending = "/etc/kvm.disk0.formatting" + dataPartition = "/dev/mmcblk0p3" +) + +var ( + virtualDiskPath = virtualDisk + dataDiskMarkerPath = dataDiskMarker + formatPendingPath = formatPending + dataPartitionPath = dataPartition ) var ( @@ -49,7 +60,7 @@ func (s *Service) GetVirtualDevice(c *gin.Context) { var rsp proto.Response network, _ := isDeviceExist(virtualNetwork) - disk, _ := isDeviceExist(virtualDisk) + disk := isVirtualDiskConfigured() rsp.OkRspWithData(c, &proto.GetVirtualDeviceRsp{ Network: network, @@ -81,14 +92,8 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) { commands = unmountNetworkCommands } case "disk": - device = virtualDisk - - exist, _ := isDeviceExist(device) - if !exist { - commands = mountDiskCommands - } else { - commands = unmountDiskCommands - } + device = virtualDiskPath + commands = virtualDiskCommands() default: rsp.ErrRsp(c, -2, "invalid arguments") return @@ -111,6 +116,9 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) { } on, _ := isDeviceExist(device) + if req.Device == "disk" { + on = isVirtualDiskConfigured() + } rsp.OkRspWithData(c, &proto.UpdateVirtualDeviceRsp{ On: on, }) @@ -118,6 +126,19 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) { log.Debugf("update virtual device %s success", req.Device) } +func virtualDiskCommands() []string { + return virtualdisk.Commands(isVirtualDiskConfigured(), mountDiskCommands, unmountDiskCommands) +} + +func isVirtualDiskConfigured() bool { + return virtualdisk.IsConfigured(virtualdisk.Paths{ + Config: virtualDiskPath, + Marker: dataDiskMarkerPath, + Pending: formatPendingPath, + Partition: dataPartitionPath, + }) +} + func isDeviceExist(device string) (bool, error) { _, err := os.Stat(device) diff --git a/server/service/vm/virtualdisk/state.go b/server/service/vm/virtualdisk/state.go new file mode 100644 index 00000000..6ff3f03a --- /dev/null +++ b/server/service/vm/virtualdisk/state.go @@ -0,0 +1,43 @@ +package virtualdisk + +import ( + "os" + "strings" +) + +type Paths struct { + Config string + Marker string + Pending string + Partition string +} + +func IsConfigured(paths Paths) bool { + content, err := os.ReadFile(paths.Config) + if err != nil { + return false + } + + disk := strings.TrimSpace(string(content)) + if disk != "" && disk != paths.Partition { + return true + } + + if _, err := os.Stat(paths.Marker); err != nil { + return false + } + if _, err := os.Stat(paths.Pending); err == nil { + return false + } + if _, err := os.Stat(paths.Partition); err != nil { + return false + } + return true +} + +func Commands(configured bool, mountCommands, unmountCommands []string) []string { + if configured { + return unmountCommands + } + return mountCommands +} diff --git a/server/service/vm/virtualdisk/state_test.go b/server/service/vm/virtualdisk/state_test.go new file mode 100644 index 00000000..49893dea --- /dev/null +++ b/server/service/vm/virtualdisk/state_test.go @@ -0,0 +1,138 @@ +package virtualdisk + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func testPaths(t *testing.T) Paths { + t.Helper() + + dir := t.TempDir() + return Paths{ + Config: filepath.Join(dir, "usb.disk0"), + Marker: filepath.Join(dir, "kvm.disk0"), + Pending: filepath.Join(dir, "kvm.disk0.formatting"), + Partition: filepath.Join(dir, "mmcblk0p3"), + } +} + +func touch(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("touch %s: %v", path, err) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestIsConfigured(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, paths Paths) + want bool + }{ + { + name: "ready default data partition", + setup: func(t *testing.T, paths Paths) { + touch(t, paths.Config) + touch(t, paths.Marker) + touch(t, paths.Partition) + }, + want: true, + }, + { + name: "explicit ready data partition", + setup: func(t *testing.T, paths Paths) { + writeFile(t, paths.Config, paths.Partition+"\n") + touch(t, paths.Marker) + touch(t, paths.Partition) + }, + want: true, + }, + { + name: "explicit ready data partition with whitespace", + setup: func(t *testing.T, paths Paths) { + writeFile(t, paths.Config, " "+paths.Partition+" \n") + touch(t, paths.Marker) + touch(t, paths.Partition) + }, + want: true, + }, + { + name: "pending data partition", + setup: func(t *testing.T, paths Paths) { + touch(t, paths.Config) + touch(t, paths.Marker) + touch(t, paths.Pending) + touch(t, paths.Partition) + }, + }, + { + name: "missing ready marker", + setup: func(t *testing.T, paths Paths) { + touch(t, paths.Config) + touch(t, paths.Partition) + }, + }, + { + name: "missing data partition", + setup: func(t *testing.T, paths Paths) { + touch(t, paths.Config) + touch(t, paths.Marker) + }, + }, + { + name: "custom backing file", + setup: func(t *testing.T, paths Paths) { + writeFile(t, paths.Config, "/data/custom.img\n") + }, + want: true, + }, + { + name: "custom backing file ignores pending data partition", + setup: func(t *testing.T, paths Paths) { + writeFile(t, paths.Config, "/data/custom.img\n") + touch(t, paths.Marker) + touch(t, paths.Pending) + touch(t, paths.Partition) + }, + want: true, + }, + { + name: "no disk config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + paths := testPaths(t) + if tt.setup != nil { + tt.setup(t, paths) + } + + if got := IsConfigured(paths); got != tt.want { + t.Fatalf("IsConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCommands(t *testing.T) { + mount := []string{"mount"} + unmount := []string{"unmount"} + + if got := Commands(false, mount, unmount); !reflect.DeepEqual(got, mount) { + t.Fatalf("Commands(false) = %#v, want mount commands", got) + } + if got := Commands(true, mount, unmount); !reflect.DeepEqual(got, unmount) { + t.Fatalf("Commands(true) = %#v, want unmount commands", got) + } +} diff --git a/tools/test-s01fs-data-disk.sh b/tools/test-s01fs-data-disk.sh new file mode 100755 index 00000000..c7d6857e --- /dev/null +++ b/tools/test-s01fs-data-disk.sh @@ -0,0 +1,507 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +s01_script="$repo_root/kvmapp/system/init.d/S01fs" +s03_script="$repo_root/kvmapp/system/init.d/S03usbdev" +real_dd="$(command -v dd)" +real_mkdir="$(command -v mkdir)" + +tmpdir="" +original_path="$PATH" + +make_stubs() { + local bin="$1" + + cat >"$bin/mount" <<'EOF' +#!/bin/sh +printf 'mount %s\n' "$*" >> "$NANOKVM_TEST_LOG" +if [ "${NANOKVM_MOUNT_FAIL:-0}" = "1" ]; then + exit 1 +fi +exit 0 +EOF + + cat >"$bin/mkdir" <<'EOF' +#!/bin/sh +"$NANOKVM_REAL_MKDIR" "$@" +for path do + case "$path" in + */functions/mass_storage.disk0|functions/mass_storage.disk0) + "$NANOKVM_REAL_MKDIR" -p "$path/lun.0" + ;; + esac +done +EOF + + cat >"$bin/resize2fs" <<'EOF' +#!/bin/sh +printf 'resize2fs %s\n' "$*" >> "$NANOKVM_TEST_LOG" +exit 0 +EOF + + cat >"$bin/sleep" <<'EOF' +#!/bin/sh +if [ "${NANOKVM_DELAY_DATA_PART_UNTIL_SLEEP:-0}" = "1" ] && [ ! -e "$NANOKVM_DATA_PART" ]; then + : > "$NANOKVM_DATA_PART" +fi +exit 0 +EOF + + cat >"$bin/parted" <<'EOF' +#!/bin/sh +printf 'parted %s\n' "$*" >> "$NANOKVM_TEST_LOG" +case " $* " in + *" mkpart primary 8193MB 100% "*) + if [ "${NANOKVM_DELAY_DATA_PART_UNTIL_SLEEP:-0}" != "1" ] && [ "${NANOKVM_NEVER_CREATE_DATA_PART:-0}" != "1" ]; then + : > "$NANOKVM_DATA_PART" + if [ "${NANOKVM_PARTED_CREATES_STALE_FS:-0}" = "1" ]; then + : > "$NANOKVM_DATA_PART.hasfs" + fi + fi + ;; +esac +exit 0 +EOF + + cat >"$bin/blkid" <<'EOF' +#!/bin/sh +printf 'blkid %s\n' "$*" >> "$NANOKVM_TEST_LOG" +if [ -e "$1.hasfs" ]; then + printf '%s: UUID="test" TYPE="exfat"\n' "$1" +fi +exit 0 +EOF + + cat >"$bin/mkfs.exfat" <<'EOF' +#!/bin/sh +printf 'mkfs.exfat %s\n' "$*" >> "$NANOKVM_TEST_LOG" +if [ "${NANOKVM_MKFS_FAIL:-0}" = "1" ]; then + exit 1 +fi +: > "$1.hasfs" +exit 0 +EOF + + cat >"$bin/dd" <<'EOF' +#!/bin/sh +printf 'dd %s\n' "$*" >> "$NANOKVM_TEST_LOG" +exec "$NANOKVM_REAL_DD" "$@" +EOF + + chmod +x "$bin/mount" "$bin/mkdir" "$bin/resize2fs" "$bin/sleep" "$bin/parted" "$bin/blkid" "$bin/mkfs.exfat" "$bin/dd" +} + +setup_case() { + tmpdir="$(mktemp -d)" + original_path="$PATH" + mkdir -p "$tmpdir/bin" "$tmpdir/boot" "$tmpdir/data" "$tmpdir/dev" "$tmpdir/etc" + make_stubs "$tmpdir/bin" + + export PATH="$tmpdir/bin:$PATH" + export NANOKVM_TEST_LOG="$tmpdir/log" + export NANOKVM_DISK="$tmpdir/dev/mmcblk0" + export NANOKVM_BOOT_PART="$tmpdir/dev/mmcblk0p1" + export NANOKVM_ROOT_PART="$tmpdir/dev/mmcblk0p2" + export NANOKVM_DATA_PART="$tmpdir/dev/mmcblk0p3" + export NANOKVM_BOOT_DIR="$tmpdir/boot" + export NANOKVM_DATA_DIR="$tmpdir/data" + export NANOKVM_DISK0_MARKER="$tmpdir/etc/kvm.disk0" + export NANOKVM_DATA_FORMAT_PENDING="$tmpdir/etc/kvm.disk0.formatting" + export NANOKVM_PROFILE="$tmpdir/profile" + export NANOKVM_REAL_DD="$real_dd" + export NANOKVM_REAL_MKDIR="$real_mkdir" + + : > "$NANOKVM_DISK" + : > "$NANOKVM_BOOT_PART" + : > "$NANOKVM_ROOT_PART" + : > "$NANOKVM_BOOT_DIR/usb.disk0" + : > "$NANOKVM_TEST_LOG" + : > "$NANOKVM_PROFILE" +} + +teardown_case() { + PATH="$original_path" + export PATH + if [ -n "$tmpdir" ]; then + rm -rf "$tmpdir" + fi + tmpdir="" +} + +run_s01() { + "$s01_script" start >/dev/null +} + +load_s03_helpers() { + # shellcheck disable=SC1090 + . <(sed -n '1,/^start_usb_dev()/ { /^start_usb_dev()/q; p; }' "$s03_script") +} + +run_case() { + local name="$1" + local setup_fn="$2" + local run_fn="$3" + local assert_fn="$4" + + setup_case + trap teardown_case RETURN + "$setup_fn" + "$run_fn" + "$assert_fn" + teardown_case + trap - RETURN + printf 'ok - %s\n' "$name" +} + +noop() { + : +} + +exists() { + [ -e "$1" ] +} + +missing() { + [ ! -e "$1" ] +} + +log_contains() { + local pattern="$1" + if ! grep -Fq "$pattern" "$NANOKVM_TEST_LOG"; then + printf 'expected log to contain: %s\n' "$pattern" >&2 + cat "$NANOKVM_TEST_LOG" >&2 + exit 1 + fi +} + +log_not_contains() { + local pattern="$1" + if grep -Fq "$pattern" "$NANOKVM_TEST_LOG"; then + printf 'expected log not to contain: %s\n' "$pattern" >&2 + cat "$NANOKVM_TEST_LOG" >&2 + exit 1 + fi +} + +given_pending_partition() { + : > "$NANOKVM_DATA_PART" + : > "$NANOKVM_DISK0_MARKER" + : > "$NANOKVM_DATA_FORMAT_PENDING" +} + +assert_pending_partition_formatted() { + exists "$NANOKVM_DATA_PART.hasfs" + exists "$NANOKVM_DISK0_MARKER" + missing "$NANOKVM_DATA_FORMAT_PENDING" + log_not_contains "blkid $NANOKVM_DATA_PART" + log_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" +} + +given_format_fails() { + given_pending_partition + export NANOKVM_MKFS_FAIL=1 +} + +assert_format_failure_keeps_pending() { + missing "$NANOKVM_DISK0_MARKER" + exists "$NANOKVM_DATA_FORMAT_PENDING" + log_contains "mkfs.exfat $NANOKVM_DATA_PART" + unset NANOKVM_MKFS_FAIL +} + +given_delayed_partition_and_format_fails() { + export NANOKVM_DELAY_DATA_PART_UNTIL_SLEEP=1 + export NANOKVM_MKFS_FAIL=1 +} + +assert_delayed_partition_failure_keeps_pending() { + exists "$NANOKVM_DATA_PART" + missing "$NANOKVM_DISK0_MARKER" + exists "$NANOKVM_DATA_FORMAT_PENDING" + log_contains "parted -s $NANOKVM_DISK mkpart primary 8193MB 100%" + log_contains "mkfs.exfat $NANOKVM_DATA_PART" + unset NANOKVM_DELAY_DATA_PART_UNTIL_SLEEP + unset NANOKVM_MKFS_FAIL +} + +given_partition_never_appears() { + export NANOKVM_NEVER_CREATE_DATA_PART=1 +} + +assert_late_device_keeps_pending_without_format() { + missing "$NANOKVM_DATA_PART" + missing "$NANOKVM_DISK0_MARKER" + exists "$NANOKVM_DATA_FORMAT_PENDING" + log_contains "parted -s $NANOKVM_DISK mkpart primary 8193MB 100%" + log_not_contains "mkfs.exfat $NANOKVM_DATA_PART" + unset NANOKVM_NEVER_CREATE_DATA_PART +} + +given_mount_fails_after_format() { + given_pending_partition + export NANOKVM_MOUNT_FAIL=1 +} + +assert_mount_failure_clears_ready_marker() { + exists "$NANOKVM_DATA_PART.hasfs" + missing "$NANOKVM_DISK0_MARKER" + missing "$NANOKVM_DATA_FORMAT_PENDING" + log_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" + unset NANOKVM_MOUNT_FAIL +} + +given_unknown_existing_partition() { + printf 'user data' > "$NANOKVM_DATA_PART" + : > "$NANOKVM_DISK0_MARKER" +} + +assert_unknown_partition_ignored() { + missing "$NANOKVM_DISK0_MARKER" + log_contains "blkid $NANOKVM_DATA_PART" + log_not_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_not_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" +} + +given_empty_legacy_marked_partition() { + : > "$NANOKVM_DATA_PART" + : > "$NANOKVM_DISK0_MARKER" +} + +assert_empty_legacy_partition_recovered() { + exists "$NANOKVM_DATA_PART.hasfs" + exists "$NANOKVM_DISK0_MARKER" + missing "$NANOKVM_DATA_FORMAT_PENDING" + log_contains "blkid $NANOKVM_DATA_PART" + log_contains "dd if=$NANOKVM_DATA_PART" + log_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" +} + +given_empty_unmarked_partition() { + : > "$NANOKVM_DATA_PART" +} + +assert_empty_unmarked_partition_ignored() { + missing "$NANOKVM_DISK0_MARKER" + log_contains "blkid $NANOKVM_DATA_PART" + log_not_contains "dd if=$NANOKVM_DATA_PART" + log_not_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_not_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" +} + +given_existing_filesystem() { + : > "$NANOKVM_DATA_PART" + : > "$NANOKVM_DATA_PART.hasfs" +} + +assert_existing_filesystem_mounted() { + log_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" + exists "$NANOKVM_DISK0_MARKER" + log_contains "blkid $NANOKVM_DATA_PART" + log_not_contains "mkfs.exfat $NANOKVM_DATA_PART" +} + +given_existing_filesystem_mount_fails() { + given_existing_filesystem + : > "$NANOKVM_DISK0_MARKER" + export NANOKVM_MOUNT_FAIL=1 +} + +assert_existing_filesystem_mount_failure_clears_marker() { + missing "$NANOKVM_DISK0_MARKER" + log_contains "blkid $NANOKVM_DATA_PART" + log_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" + log_not_contains "mkfs.exfat $NANOKVM_DATA_PART" + unset NANOKVM_MOUNT_FAIL +} + +given_new_partition_with_stale_signature() { + export NANOKVM_PARTED_CREATES_STALE_FS=1 +} + +assert_new_partition_formatted_despite_stale_signature() { + exists "$NANOKVM_DATA_PART" + exists "$NANOKVM_DISK0_MARKER" + missing "$NANOKVM_DATA_FORMAT_PENDING" + log_contains "parted -s $NANOKVM_DISK mkpart primary 8193MB 100%" + log_not_contains "blkid $NANOKVM_DATA_PART" + log_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" + unset NANOKVM_PARTED_CREATES_STALE_FS +} + +assert_missing_partition_created_and_formatted() { + exists "$NANOKVM_DATA_PART" + exists "$NANOKVM_DATA_PART.hasfs" + exists "$NANOKVM_DISK0_MARKER" + missing "$NANOKVM_DATA_FORMAT_PENDING" + log_contains "parted -s $NANOKVM_DISK mkpart primary 8193MB 100%" + log_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" +} + +given_custom_backing_file_configured() { + printf '%s\n' "$tmpdir/custom.img" > "$NANOKVM_BOOT_DIR/usb.disk0" +} + +given_custom_backing_with_pending_default_partition() { + given_custom_backing_file_configured + : > "$NANOKVM_DATA_PART" + : > "$NANOKVM_DATA_FORMAT_PENDING" +} + +given_custom_backing_with_unknown_marked_partition() { + given_custom_backing_file_configured + printf 'user data' > "$NANOKVM_DATA_PART" + : > "$NANOKVM_DISK0_MARKER" +} + +assert_custom_backing_does_not_create_default_partition() { + missing "$NANOKVM_DATA_PART" + missing "$NANOKVM_DISK0_MARKER" + missing "$NANOKVM_DATA_FORMAT_PENDING" + log_not_contains "parted -s $NANOKVM_DISK mkpart primary 8193MB 100%" + log_not_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_not_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" +} + +assert_pending_default_partition_retried_with_custom_backing() { + exists "$NANOKVM_DATA_PART.hasfs" + exists "$NANOKVM_DISK0_MARKER" + missing "$NANOKVM_DATA_FORMAT_PENDING" + log_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" +} + +assert_custom_backing_unknown_partition_clears_marker() { + missing "$NANOKVM_DISK0_MARKER" + log_contains "blkid $NANOKVM_DATA_PART" + log_not_contains "mkfs.exfat $NANOKVM_DATA_PART" + log_not_contains "mount $NANOKVM_DATA_PART $NANOKVM_DATA_DIR" +} + +given_explicit_default_partition_configured() { + printf '%s\n' "$NANOKVM_DATA_PART" > "$NANOKVM_BOOT_DIR/usb.disk0" +} + +given_explicit_default_partition_with_whitespace_configured() { + printf ' %s \n' "$NANOKVM_DATA_PART" > "$NANOKVM_BOOT_DIR/usb.disk0" +} + +run_s03_helper() { + load_s03_helpers +} + +run_s03_mass_storage() { + load_s03_helpers + mkdir -p "$tmpdir/gadget/functions" "$tmpdir/gadget/configs/c.1" + ( + cd "$tmpdir/gadget" + configure_mass_storage + ) +} + +given_s03_ready_default() { + : > "$NANOKVM_DATA_PART" + : > "$NANOKVM_DISK0_MARKER" +} + +assert_s03_default_ready() { + [ "$(default_data_disk_file)" = "$NANOKVM_DATA_PART" ] + [ "$(resolve_disk_file "")" = "$NANOKVM_DATA_PART" ] + [ "$(resolve_disk_file "$NANOKVM_DATA_PART")" = "$NANOKVM_DATA_PART" ] +} + +given_s03_pending_default() { + given_s03_ready_default + : > "$NANOKVM_DATA_FORMAT_PENDING" +} + +assert_s03_default_hidden() { + [ -z "$(default_data_disk_file)" ] + [ -z "$(resolve_disk_file "")" ] + [ -z "$(resolve_disk_file "$NANOKVM_DATA_PART")" ] +} + +given_s03_missing_marker() { + : > "$NANOKVM_DATA_PART" +} + +given_s03_missing_device() { + : > "$NANOKVM_DISK0_MARKER" +} + +assert_s03_custom_disk_allowed() { + [ "$(resolve_disk_file "/data/custom.img")" = "/data/custom.img" ] + [ "$(resolve_disk_file " /data/custom.img ")" = "/data/custom.img" ] +} + +assert_s03_explicit_default_with_whitespace_ready() { + [ "$(resolve_disk_file " $NANOKVM_DATA_PART ")" = "$NANOKVM_DATA_PART" ] +} + +assert_s03_explicit_default_with_whitespace_hidden() { + [ -z "$(resolve_disk_file " $NANOKVM_DATA_PART ")" ] +} + +assert_s03_mass_storage_created_for_ready_default() { + exists "$tmpdir/gadget/functions/mass_storage.disk0" + [ -L "$tmpdir/gadget/configs/c.1/mass_storage.disk0" ] + [ "$(cat "$tmpdir/gadget/functions/mass_storage.disk0/lun.0/file")" = "$NANOKVM_DATA_PART" ] + [ "$(cat "$tmpdir/gadget/functions/mass_storage.disk0/lun.0/removable")" = "1" ] + [ "$(cat "$tmpdir/gadget/functions/mass_storage.disk0/lun.0/inquiry_string")" = "NanoKVM USB Mass Storage0520" ] +} + +assert_s03_mass_storage_not_created() { + missing "$tmpdir/gadget/functions/mass_storage.disk0" + missing "$tmpdir/gadget/configs/c.1/mass_storage.disk0" +} + +given_s03_custom_disk() { + printf '%s\n' "$tmpdir/custom.img" > "$NANOKVM_BOOT_DIR/usb.disk0" +} + +given_s03_explicit_default_with_whitespace() { + given_s03_ready_default + printf ' %s \n' "$NANOKVM_DATA_PART" > "$NANOKVM_BOOT_DIR/usb.disk0" +} + +assert_s03_mass_storage_created_for_custom_disk() { + exists "$tmpdir/gadget/functions/mass_storage.disk0" + [ -L "$tmpdir/gadget/configs/c.1/mass_storage.disk0" ] + [ "$(cat "$tmpdir/gadget/functions/mass_storage.disk0/lun.0/file")" = "$tmpdir/custom.img" ] +} + +run_case "retries pending unformatted partition" given_pending_partition run_s01 assert_pending_partition_formatted +run_case "keeps pending marker when mkfs fails" given_format_fails run_s01 assert_format_failure_keeps_pending +run_case "keeps pending marker when delayed partition format fails" given_delayed_partition_and_format_fails run_s01 assert_delayed_partition_failure_keeps_pending +run_case "keeps pending marker when new partition device is late" given_partition_never_appears run_s01 assert_late_device_keeps_pending_without_format +run_case "removes marker when mount fails after format" given_mount_fails_after_format run_s01 assert_mount_failure_clears_ready_marker +run_case "does not format or mount unknown existing partition" given_unknown_existing_partition run_s01 assert_unknown_partition_ignored +run_case "recovers zero-filled legacy marked partition" given_empty_legacy_marked_partition run_s01 assert_empty_legacy_partition_recovered +run_case "does not recover zero-filled partition without legacy marker" given_empty_unmarked_partition run_s01 assert_empty_unmarked_partition_ignored +run_case "mounts existing filesystem without formatting" given_existing_filesystem run_s01 assert_existing_filesystem_mounted +run_case "removes marker when existing filesystem mount fails" given_existing_filesystem_mount_fails run_s01 assert_existing_filesystem_mount_failure_clears_marker +run_case "formats new partition even with stale signature" given_new_partition_with_stale_signature run_s01 assert_new_partition_formatted_despite_stale_signature +run_case "creates and formats missing partition" noop run_s01 assert_missing_partition_created_and_formatted +run_case "does not create default partition for custom backing file" given_custom_backing_file_configured run_s01 assert_custom_backing_does_not_create_default_partition +run_case "retries pending default partition with custom backing file" given_custom_backing_with_pending_default_partition run_s01 assert_pending_default_partition_retried_with_custom_backing +run_case "clears stale marker for unknown partition with custom backing file" given_custom_backing_with_unknown_marked_partition run_s01 assert_custom_backing_unknown_partition_clears_marker +run_case "creates default partition when explicitly configured" given_explicit_default_partition_configured run_s01 assert_missing_partition_created_and_formatted +run_case "trims explicit default partition config before creating it" given_explicit_default_partition_with_whitespace_configured run_s01 assert_missing_partition_created_and_formatted +run_case "S03 exposes default data disk only when ready" given_s03_ready_default run_s03_helper assert_s03_default_ready +run_case "S03 hides default and explicit p3 when format is pending" given_s03_pending_default run_s03_helper assert_s03_default_hidden +run_case "S03 hides default and explicit p3 without ready marker" given_s03_missing_marker run_s03_helper assert_s03_default_hidden +run_case "S03 hides default and explicit p3 when device is missing" given_s03_missing_device run_s03_helper assert_s03_default_hidden +run_case "S03 still allows custom backing files" noop run_s03_helper assert_s03_custom_disk_allowed +run_case "S03 trims explicit p3 before resolving readiness" given_s03_ready_default run_s03_helper assert_s03_explicit_default_with_whitespace_ready +run_case "S03 hides whitespace explicit p3 while format is pending" given_s03_pending_default run_s03_helper assert_s03_explicit_default_with_whitespace_hidden +run_case "S03 creates mass storage for ready default data disk" given_s03_ready_default run_s03_mass_storage assert_s03_mass_storage_created_for_ready_default +run_case "S03 skips mass storage while default data disk is pending" given_s03_pending_default run_s03_mass_storage assert_s03_mass_storage_not_created +run_case "S03 creates mass storage for custom backing files" given_s03_custom_disk run_s03_mass_storage assert_s03_mass_storage_created_for_custom_disk +run_case "S03 trims explicit p3 before creating mass storage" given_s03_explicit_default_with_whitespace run_s03_mass_storage assert_s03_mass_storage_created_for_ready_default + +echo "S01fs data disk tests passed"