From ca533b1771dd1bc9e79828cfa8615d2af7913abd Mon Sep 17 00:00:00 2001 From: Anthony Sosso Date: Sat, 4 Apr 2026 23:01:21 -0700 Subject: [PATCH 1/5] feat(vm): add --swap-size flag for dedicated swap disk Adds a --swap-size flag that attaches a virtio block device formatted as swap. The host creates a sparse file, the guest-init writes a minimal swap header and calls swapon at boot. Tested: 512MB RAM VM with 1GB swap survived a 450MB stress-ng allocation that would otherwise OOM. Swap usage confirmed via /proc/swaps and pswpin/pswpout counters. Note: swap is currently unencrypted. Secrets in guest memory can be paged to the host-side swap.img file. Encrypted swap via dm-crypt (requires kernel config additions) is planned as a follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/guest-init/main.go | 89 +++++++++++++++++++++++++++++++++++ cmd/matchlock/cmd_run.go | 4 ++ pkg/api/config.go | 1 + pkg/sandbox/rootfs.go | 24 ++++++++++ pkg/sandbox/sandbox_darwin.go | 12 +++++ pkg/sandbox/sandbox_linux.go | 12 +++++ pkg/vm/backend.go | 1 + pkg/vm/darwin/backend.go | 21 +++++++++ pkg/vm/linux/backend.go | 12 +++++ 9 files changed, 176 insertions(+) diff --git a/cmd/guest-init/main.go b/cmd/guest-init/main.go index 8c35b84..38d6921 100644 --- a/cmd/guest-init/main.go +++ b/cmd/guest-init/main.go @@ -74,6 +74,7 @@ type bootConfig struct { MTU int NoNetwork bool Disks []diskMount + SwapDevice string // e.g. "vde" — swapon /dev/vde Overlay overlayBootConfig } @@ -138,6 +139,9 @@ func runInit() { if err := mountExtraDisks(cfg.Disks); err != nil { fatal(err) } + if cfg.SwapDevice != "" { + enableSwap(cfg.SwapDevice) + } if cfg.Workspace != "" { if err := startGuestFused(guestFusedPath); err != nil { @@ -216,6 +220,12 @@ func parseBootConfig(cmdlinePath string) (*bootConfig, error) { v := strings.TrimPrefix(field, "matchlock.no_network=") cfg.NoNetwork = v == "1" || strings.EqualFold(v, "true") + case strings.HasPrefix(field, "matchlock.swap="): + v := strings.TrimPrefix(field, "matchlock.swap=") + if v != "" { + cfg.SwapDevice = v + } + case strings.HasPrefix(field, "matchlock.disk."): spec := strings.TrimPrefix(field, "matchlock.disk.") i := strings.IndexByte(spec, '=') @@ -670,6 +680,85 @@ func interfaceHasIPv4(name string) (bool, error) { return false, nil } +func enableSwap(device string) { + devPath := filepath.Join("/dev", device) + + if err := writeSwapHeader(devPath); err != nil { + warnf("format swap %s: %v", devPath, err) + return + } + + pathBytes, err := unix.BytePtrFromString(devPath) + if err != nil { + warnf("swapon %s: invalid path: %v", devPath, err) + return + } + _, _, errno := unix.Syscall(unix.SYS_SWAPON, uintptr(unsafe.Pointer(pathBytes)), 0, 0) + if errno != 0 { + warnf("swapon %s failed: %v", devPath, errno) + } +} + +// writeSwapHeader writes a minimal Linux swap header so the kernel accepts +// the device via swapon. Layout (page size = 4096): +// +// offset 1024: reserved (boot sector area) +// offset 4086: "SWAPSPACE2" magic (10 bytes at end of first page) +// +// swap_header (from include/linux/swap.h) at offset 1024: +// +// u32 version = 1 +// u32 last_page = (device_size / page_size) - 1 +// u32 nr_badpages = 0 +// char uuid[16] +// char volume[16] +// u32 padding[117] +// +// Then the magic "SWAPSPACE2" at bytes 4086-4095 of the first page. +func writeSwapHeader(devPath string) error { + const pageSize = 4096 + + f, err := os.OpenFile(devPath, os.O_RDWR, 0) + if err != nil { + return err + } + defer f.Close() + + // For block devices, Stat().Size() returns 0. Use BLKGETSIZE64 ioctl. + var devSize uint64 + _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0x80081272 /* BLKGETSIZE64 */, uintptr(unsafe.Pointer(&devSize))) + if errno != 0 { + return fmt.Errorf("BLKGETSIZE64: %v", errno) + } + totalPages := int64(devSize) / pageSize + if totalPages < 10 { + return fmt.Errorf("swap device too small: %d bytes", devSize) + } + lastPage := uint32(totalPages - 1) + + // Write swap_header at offset 1024 + header := make([]byte, pageSize) + // version = 1 (little-endian u32 at offset 0 within the header struct, + // which starts at byte 1024 of the page) + header[1024] = 1 + header[1025] = 0 + header[1026] = 0 + header[1027] = 0 + // last_page (little-endian u32) + header[1028] = byte(lastPage) + header[1029] = byte(lastPage >> 8) + header[1030] = byte(lastPage >> 16) + header[1031] = byte(lastPage >> 24) + // nr_badpages = 0 (already zero) + // uuid and volume name left as zeros + + // Magic at end of first page + copy(header[pageSize-10:], "SWAPSPACE2") + + _, err = f.WriteAt(header, 0) + return err +} + func mountExtraDisks(disks []diskMount) error { for _, d := range disks { if d.Device == "" || d.Path == "" { diff --git a/cmd/matchlock/cmd_run.go b/cmd/matchlock/cmd_run.go index 3e082f6..f839990 100644 --- a/cmd/matchlock/cmd_run.go +++ b/cmd/matchlock/cmd_run.go @@ -124,6 +124,7 @@ func init() { runCmd.Flags().Int("memory", api.DefaultMemoryMB, "Memory in MB") runCmd.Flags().Int("timeout", api.DefaultTimeoutSeconds, "Timeout in seconds") runCmd.Flags().Int("disk-size", api.DefaultDiskSizeMB, "Disk size in MB") + runCmd.Flags().Int("swap-size", 0, "Swap disk size in MB (0 = no swap)") runCmd.Flags().BoolP("detach", "d", false, "Run sandbox in detached mode (implies --rm=false; incompatible with -t/-i)") runCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY") runCmd.Flags().BoolP("interactive", "i", false, "Keep STDIN open") @@ -158,6 +159,7 @@ func init() { viper.BindPFlag("run.memory", runCmd.Flags().Lookup("memory")) viper.BindPFlag("run.timeout", runCmd.Flags().Lookup("timeout")) viper.BindPFlag("run.disk-size", runCmd.Flags().Lookup("disk-size")) + viper.BindPFlag("run.swap-size", runCmd.Flags().Lookup("swap-size")) viper.BindPFlag("run.detach", runCmd.Flags().Lookup("detach")) viper.BindPFlag("run.tty", runCmd.Flags().Lookup("tty")) viper.BindPFlag("run.interactive", runCmd.Flags().Lookup("interactive")) @@ -178,6 +180,7 @@ func runRun(cmd *cobra.Command, args []string) error { cpus, _ := cmd.Flags().GetFloat64("cpus") memory, _ := cmd.Flags().GetInt("memory") diskSize, _ := cmd.Flags().GetInt("disk-size") + swapSize, _ := cmd.Flags().GetInt("swap-size") timeout, _ := cmd.Flags().GetInt("timeout") // Exec options @@ -395,6 +398,7 @@ func runRun(cmd *cobra.Command, args []string) error { CPUs: cpus, MemoryMB: memory, DiskSizeMB: diskSize, + SwapSizeMB: swapSize, TimeoutSeconds: timeout, }, Network: &api.NetworkConfig{ diff --git a/pkg/api/config.go b/pkg/api/config.go index 94725f3..d9871c7 100644 --- a/pkg/api/config.go +++ b/pkg/api/config.go @@ -70,6 +70,7 @@ type Resources struct { CPUs float64 `json:"cpus,omitempty"` MemoryMB int `json:"memory_mb,omitempty"` DiskSizeMB int `json:"disk_size_mb,omitempty"` + SwapSizeMB int `json:"swap_size_mb,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` Timeout time.Duration `json:"-"` } diff --git a/pkg/sandbox/rootfs.go b/pkg/sandbox/rootfs.go index abff08f..9c90c80 100644 --- a/pkg/sandbox/rootfs.go +++ b/pkg/sandbox/rootfs.go @@ -49,6 +49,30 @@ func createExt4Image(path string, sizeMB int64) error { return nil } +// createSwapImage creates a sparse file to be used as a swap device. +// The file is NOT formatted here — guest-init runs mkswap inside the VM +// because mkswap is a Linux tool not available on macOS hosts. +func createSwapImage(path string, sizeMB int64) error { + if sizeMB <= 0 { + return fmt.Errorf("invalid swap size %dMB", sizeMB) + } + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + if err := f.Truncate(sizeMB * 1024 * 1024); err != nil { + _ = f.Close() + _ = os.Remove(path) + return fmt.Errorf("truncate %s: %w", path, err) + } + if err := f.Close(); err != nil { + _ = os.Remove(path) + return fmt.Errorf("close %s: %w", path, err) + } + return nil +} + func createBootstrapRootfs(path string) error { if err := createExt4Image(path, defaultBootstrapRootfsMB); err != nil { return errx.Wrap(ErrPrepareBootstrapRoot, err) diff --git a/pkg/sandbox/sandbox_darwin.go b/pkg/sandbox/sandbox_darwin.go index f011534..c7376e8 100644 --- a/pkg/sandbox/sandbox_darwin.go +++ b/pkg/sandbox/sandbox_darwin.go @@ -217,6 +217,17 @@ func New(ctx context.Context, config *api.Config, opts *Options) (sb *Sandbox, r return nil, err } + // Create swap disk image if requested. + var swapPath string + if config.Resources != nil && config.Resources.SwapSizeMB > 0 { + swapPath = filepath.Join(stateMgr.Dir(id), "swap.img") + if err := createSwapImage(swapPath, int64(config.Resources.SwapSizeMB)); err != nil { + releaseSubnet() + stateMgr.Unregister(id) + return nil, errx.With(ErrCreateVM, " swap: %w", err) + } + } + gatewayIP := "" guestIP := "" subnetCIDR := "" @@ -246,6 +257,7 @@ func New(ctx context.Context, config *api.Config, opts *Options) (sb *Sandbox, r Privileged: config.Privileged, PrebuiltRootfs: bootstrapRootfsPath, ExtraDisks: extraDisks, + SwapPath: swapPath, DNSServers: config.Network.GetDNSServers(), Hostname: hostname, AddHosts: config.Network.AddHosts, diff --git a/pkg/sandbox/sandbox_linux.go b/pkg/sandbox/sandbox_linux.go index ef99a64..7d87601 100644 --- a/pkg/sandbox/sandbox_linux.go +++ b/pkg/sandbox/sandbox_linux.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "path/filepath" "runtime" "github.com/jingkaihe/matchlock/internal/errx" @@ -234,6 +235,16 @@ func New(ctx context.Context, config *api.Config, opts *Options) (sb *Sandbox, r return nil, err } + var swapPath string + if config.Resources != nil && config.Resources.SwapSizeMB > 0 { + swapPath = filepath.Join(stateMgr.Dir(id), "swap.img") + if err := createSwapImage(swapPath, int64(config.Resources.SwapSizeMB)); err != nil { + releaseSubnet() + stateMgr.Unregister(id) + return nil, errx.With(ErrCreateVM, " swap: %w", err) + } + } + gatewayIP := "" guestIP := "" subnetCIDR := "" @@ -263,6 +274,7 @@ func New(ctx context.Context, config *api.Config, opts *Options) (sb *Sandbox, r Workspace: workspace, Privileged: config.Privileged, ExtraDisks: extraDisks, + SwapPath: swapPath, DNSServers: config.Network.GetDNSServers(), Hostname: hostname, AddHosts: config.Network.AddHosts, diff --git a/pkg/vm/backend.go b/pkg/vm/backend.go index c1050e1..8ff4071 100644 --- a/pkg/vm/backend.go +++ b/pkg/vm/backend.go @@ -46,6 +46,7 @@ type VMConfig struct { NoNetwork bool // Disable guest network interface entirely PrebuiltRootfs string // Pre-prepared rootfs path (skips internal copy if set) ExtraDisks []DiskConfig // Additional block devices to attach + SwapPath string // Host path to swap disk image (attached as virtio block device) } type Backend interface { diff --git a/pkg/vm/darwin/backend.go b/pkg/vm/darwin/backend.go index 00e9901..a7c7dbe 100644 --- a/pkg/vm/darwin/backend.go +++ b/pkg/vm/darwin/backend.go @@ -244,6 +244,10 @@ func (b *DarwinBackend) buildKernelArgs(config *vm.VMConfig) string { } diskArgs += fmt.Sprintf(" matchlock.disk.%s=%s", dev, diskMount) } + if config.SwapPath != "" { + diskArgs += fmt.Sprintf(" matchlock.swap=vd%c", devLetter) + devLetter++ + } addHostArgs := "" for i, mapping := range config.AddHosts { @@ -356,6 +360,23 @@ func (b *DarwinBackend) configureStorage(vzConfig *vz.VirtualMachineConfiguratio devices = append(devices, extraConfig) } + if config.SwapPath != "" { + swapAttachment, err := vz.NewDiskImageStorageDeviceAttachmentWithCacheAndSync( + config.SwapPath, + false, + vz.DiskImageCachingModeAutomatic, + vz.DiskImageSynchronizationModeFsync, + ) + if err != nil { + return errx.Wrap(ErrDiskAttachment, err) + } + swapConfig, err := vz.NewVirtioBlockDeviceConfiguration(swapAttachment) + if err != nil { + return errx.Wrap(ErrStorageConfig, err) + } + devices = append(devices, swapConfig) + } + vzConfig.SetStorageDevicesVirtualMachineConfiguration(devices) return nil } diff --git a/pkg/vm/linux/backend.go b/pkg/vm/linux/backend.go index fc063ff..0a6cb7f 100644 --- a/pkg/vm/linux/backend.go +++ b/pkg/vm/linux/backend.go @@ -337,6 +337,10 @@ func (m *LinuxMachine) generateFirecrackerConfig() []byte { } kernelArgs += fmt.Sprintf(" matchlock.disk.%s=%s", dev, diskMount) } + if m.config.SwapPath != "" { + kernelArgs += fmt.Sprintf(" matchlock.swap=vd%c", devLetter) + devLetter++ + } for i, mapping := range m.config.AddHosts { kernelArgs += fmt.Sprintf(" matchlock.add_host.%d=%s,%s", i, mapping.Host, mapping.IP) } @@ -376,6 +380,14 @@ func (m *LinuxMachine) generateFirecrackerConfig() []byte { IsReadOnly: disk.ReadOnly, }) } + if m.config.SwapPath != "" { + drives = append(drives, fcDrive{ + DriveID: "swap", + PathOnHost: m.config.SwapPath, + IsRootDevice: false, + IsReadOnly: false, + }) + } type fcConfig struct { BootSource struct { From 7209ef2274f32747ae681e397c9b259ed34c211a Mon Sep 17 00:00:00 2001 From: Anthony Sosso Date: Sat, 4 Apr 2026 23:15:02 -0700 Subject: [PATCH 2/5] feat(vm): encrypt swap via dm-crypt with ephemeral key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest-init sets up dm-crypt on the swap block device using a random 256-bit key (aes-xts-plain64) before formatting and enabling swap. The key exists only in guest kernel memory and is lost when the VM stops, making swapped data irrecoverable from the host-side swap.img. Uses device-mapper ioctls directly — no cryptsetup binary needed. Falls back to unencrypted swap if CONFIG_DM_CRYPT is absent. Kernel config additions (both arm64 and x86_64): CONFIG_MD=y, CONFIG_BLK_DEV_DM=y, CONFIG_DM_CRYPT=y, CONFIG_CRYPTO_AES=y, CONFIG_CRYPTO_XTS=y Tested: wrote 5M copies of a known string in the guest, forced 332MB to swap, then searched the host-side swap.img — zero plaintext matches. The raw file contains only ciphertext. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/guest-init/main.go | 193 +++++++++++++++++++++++++++++++++++-- guest/kernel/arm64.config | 8 ++ guest/kernel/x86_64.config | 8 ++ 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/cmd/guest-init/main.go b/cmd/guest-init/main.go index 38d6921..fe8629f 100644 --- a/cmd/guest-init/main.go +++ b/cmd/guest-init/main.go @@ -7,7 +7,9 @@ package main import ( "bufio" + "encoding/binary" "fmt" + "io" "math" "net" "os" @@ -683,20 +685,199 @@ func interfaceHasIPv4(name string) (bool, error) { func enableSwap(device string) { devPath := filepath.Join("/dev", device) - if err := writeSwapHeader(devPath); err != nil { - warnf("format swap %s: %v", devPath, err) - return + // Try encrypted swap via dm-crypt first. Falls back to unencrypted + // if dm-crypt is not available (kernel without CONFIG_DM_CRYPT). + swapDev, err := setupEncryptedSwap(devPath) + if err != nil { + warnf("encrypted swap setup failed, using unencrypted: %v", err) + swapDev = devPath + if err := writeSwapHeader(swapDev); err != nil { + warnf("format swap %s: %v", swapDev, err) + return + } } - pathBytes, err := unix.BytePtrFromString(devPath) + pathBytes, err := unix.BytePtrFromString(swapDev) if err != nil { - warnf("swapon %s: invalid path: %v", devPath, err) + warnf("swapon %s: invalid path: %v", swapDev, err) return } _, _, errno := unix.Syscall(unix.SYS_SWAPON, uintptr(unsafe.Pointer(pathBytes)), 0, 0) if errno != 0 { - warnf("swapon %s failed: %v", devPath, errno) + warnf("swapon %s failed: %v", swapDev, errno) + } +} + +// setupEncryptedSwap creates a dm-crypt device on top of the raw block device +// using a random ephemeral key. The key exists only in kernel memory and is +// lost when the VM stops, making the on-disk swap data irrecoverable. +// +// Uses the device-mapper ioctl interface directly (no cryptsetup binary needed). +// Returns the path to the dm device (e.g. /dev/dm-0). +func setupEncryptedSwap(devPath string) (string, error) { + // Get the device size in 512-byte sectors. + f, err := os.OpenFile(devPath, os.O_RDONLY, 0) + if err != nil { + return "", err + } + var devSize uint64 + _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0x80081272 /* BLKGETSIZE64 */, uintptr(unsafe.Pointer(&devSize))) + f.Close() + if errno != 0 { + return "", fmt.Errorf("BLKGETSIZE64: %v", errno) + } + sectors := devSize / 512 + + // Generate a random 256-bit key. + key := make([]byte, 32) + if _, err := io.ReadFull(cryptoRandReader(), key); err != nil { + return "", fmt.Errorf("generate key: %w", err) + } + hexKey := fmt.Sprintf("%x", key) + + // Open /dev/mapper/control. + ctrl, err := os.OpenFile("/dev/mapper/control", os.O_RDWR, 0) + if err != nil { + return "", fmt.Errorf("open /dev/mapper/control: %w", err) + } + defer ctrl.Close() + + const dmName = "swap-crypt" + + // DM_DEV_CREATE — create a new device. + dmDev, err := dmIoctl(ctrl, 0xc138fd03 /* DM_DEV_CREATE */, dmName, nil) + if err != nil { + return "", fmt.Errorf("DM_DEV_CREATE: %w", err) + } + minor := unix.Minor(dmDev) + + // On failure after create, remove the dm device so the raw device isn't left busy. + removeDM := func() { + dmIoctl(ctrl, 0xc138fd04 /* DM_DEV_REMOVE */, dmName, nil) + } + + // DM_TABLE_LOAD — load the crypt target. + // Target line: "0 crypt aes-xts-plain64 0 0" + targetParams := fmt.Sprintf("aes-xts-plain64 %s 0 %s 0", hexKey, devPath) + spec := dmTargetSpec(0, sectors, "crypt", targetParams) + if _, err := dmIoctl(ctrl, 0xc138fd09 /* DM_TABLE_LOAD */, dmName, spec); err != nil { + removeDM() + return "", fmt.Errorf("DM_TABLE_LOAD: %w", err) + } + + // DM_DEV_SUSPEND (resume) — activate the device. + if _, err := dmIoctl(ctrl, 0xc138fd06 /* DM_DEV_SUSPEND */, dmName, nil); err != nil { + removeDM() + return "", fmt.Errorf("DM_DEV_SUSPEND: %w", err) + } + + // The dm device is /dev/dm-. Write swap header to it. + mapperDev := fmt.Sprintf("/dev/dm-%d", minor) + + // Wait briefly for the device node to appear. + for i := 0; i < 20; i++ { + if _, err := os.Stat(mapperDev); err == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + + if err := writeSwapHeader(mapperDev); err != nil { + removeDM() + return "", fmt.Errorf("write swap header on %s: %w", mapperDev, err) + } + + return mapperDev, nil +} + +func cryptoRandReader() io.Reader { + f, err := os.Open("/dev/urandom") + if err != nil { + // Should never happen in a Linux VM. + panic("open /dev/urandom: " + err.Error()) + } + return f +} + +// dmIoctl performs a device-mapper ioctl. The ioctl buffer layout is: +// +// struct dm_ioctl { +// u32 version[3]; // offset 0, 12 bytes +// u32 data_size; // offset 12 +// u32 data_start; // offset 16 +// u32 target_count; // offset 20 +// s32 open_count; // offset 24 +// u32 flags; // offset 28 +// u32 event_nr; // offset 32 +// u32 padding; // offset 36 +// u64 dev; // offset 40 +// char name[128]; // offset 48 +// char uuid[129]; // offset 176 +// char data[...]; // offset 312 (DM_NAME_LEN+DM_UUID_LEN aligned) +// }; +// +// Total header size: 312 bytes. +func dmIoctl(ctrl *os.File, cmd uintptr, name string, payload []byte) (uint64, error) { + const headerSize = 312 + bufSize := headerSize + len(payload) + if bufSize < 16384 { + bufSize = 16384 + } + buf := make([]byte, bufSize) + + // version: 4.0.0 + buf[0] = 4 + // data_size + binary.LittleEndian.PutUint32(buf[12:], uint32(bufSize)) + // data_start (where target specs begin) + binary.LittleEndian.PutUint32(buf[16:], headerSize) + // name + copy(buf[48:], name) + + if len(payload) > 0 { + copy(buf[headerSize:], payload) + // target_count = 1 + binary.LittleEndian.PutUint32(buf[20:], 1) + } + + _, _, errno := unix.Syscall(unix.SYS_IOCTL, ctrl.Fd(), cmd, uintptr(unsafe.Pointer(&buf[0]))) + if errno != 0 { + return 0, errno } + + // Return dev field (offset 40, u64). + dev := binary.LittleEndian.Uint64(buf[40:]) + return dev, nil +} + +// dmTargetSpec builds a dm_target_spec + parameter string. +// +// struct dm_target_spec { +// u64 sector_start; // offset 0 +// u64 length; // offset 8 +// s32 status; // offset 16 +// u32 next; // offset 20 (offset to next spec, 0 if last) +// char target_type[16]; // offset 24 +// }; +// +// Total: 40 bytes, followed by the null-terminated parameter string. +func dmTargetSpec(startSector, lengthSectors uint64, targetType, params string) []byte { + const specSize = 40 + paramBytes := append([]byte(params), 0) // null-terminated + total := specSize + len(paramBytes) + // Align to 8 bytes. + if total%8 != 0 { + total += 8 - (total % 8) + } + buf := make([]byte, total) + + binary.LittleEndian.PutUint64(buf[0:], startSector) + binary.LittleEndian.PutUint64(buf[8:], lengthSectors) + // status = 0, next = 0 (last target) + copy(buf[24:], targetType) + copy(buf[specSize:], paramBytes) + + return buf } // writeSwapHeader writes a minimal Linux swap header so the kernel accepts diff --git a/guest/kernel/arm64.config b/guest/kernel/arm64.config index 4c77a44..9933cc7 100644 --- a/guest/kernel/arm64.config +++ b/guest/kernel/arm64.config @@ -192,6 +192,14 @@ CONFIG_POSIX_MQUEUE=y CONFIG_SPARSEMEM=y CONFIG_SPARSEMEM_VMEMMAP=y +# Device mapper and dm-crypt (for encrypted swap) +CONFIG_MD=y +CONFIG_BLK_DEV_DM=y +CONFIG_DM_CRYPT=y +CONFIG_CRYPTO=y +CONFIG_CRYPTO_AES=y +CONFIG_CRYPTO_XTS=y + # PCI for Virtualization.framework CONFIG_PCI=y CONFIG_PCI_HOST_GENERIC=y diff --git a/guest/kernel/x86_64.config b/guest/kernel/x86_64.config index f406196..21e7523 100644 --- a/guest/kernel/x86_64.config +++ b/guest/kernel/x86_64.config @@ -200,6 +200,14 @@ CONFIG_POSIX_MQUEUE=y CONFIG_SPARSEMEM=y CONFIG_SPARSEMEM_VMEMMAP=y +# Device mapper and dm-crypt (for encrypted swap) +CONFIG_MD=y +CONFIG_BLK_DEV_DM=y +CONFIG_DM_CRYPT=y +CONFIG_CRYPTO=y +CONFIG_CRYPTO_AES=y +CONFIG_CRYPTO_XTS=y + # ACPI and PCI (required for Firecracker v1.8+) CONFIG_ACPI=n CONFIG_PCI=y From 37a10f41dda3aeed529c2acdf22d84dfa44d698c Mon Sep 17 00:00:00 2001 From: Anthony Sosso Date: Sat, 4 Apr 2026 23:31:07 -0700 Subject: [PATCH 3/5] perf(kernel): enable AES-CE hardware acceleration for arm64 Adds CONFIG_CRYPTO_AES_ARM64_CE and CONFIG_CRYPTO_AES_ARM64_CE_BLK to use ARMv8 Crypto Extensions for AES, which Apple Silicon supports. Benchmark (800M allocation in 512M RAM, 30s stress-ng): Encrypted swap (generic AES): 213 bogo ops/s Encrypted swap (AES-CE hw): 680 bogo ops/s (3.2x faster) Unencrypted swap: 2,043 bogo ops/s Encryption overhead drops from ~10x to ~3x vs unencrypted swap. Co-Authored-By: Claude Opus 4.6 (1M context) --- guest/kernel/arm64.config | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guest/kernel/arm64.config b/guest/kernel/arm64.config index 9933cc7..721e8fe 100644 --- a/guest/kernel/arm64.config +++ b/guest/kernel/arm64.config @@ -199,6 +199,8 @@ CONFIG_DM_CRYPT=y CONFIG_CRYPTO=y CONFIG_CRYPTO_AES=y CONFIG_CRYPTO_XTS=y +CONFIG_CRYPTO_AES_ARM64_CE=y +CONFIG_CRYPTO_AES_ARM64_CE_BLK=y # PCI for Virtualization.framework CONFIG_PCI=y From 34704642d2be761f49c309d17695ad20b97f5855 Mon Sep 17 00:00:00 2001 From: Anthony Sosso Date: Sat, 4 Apr 2026 23:34:53 -0700 Subject: [PATCH 4/5] feat(vm): add --encrypt-swap flag, docs, and benchmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes encrypted swap opt-out via --encrypt-swap=false (default: true). The flag flows through api.Resources -> vm.VMConfig -> kernel cmdline (matchlock.encrypt_swap=1) -> guest-init. When disabled, guest-init skips dm-crypt setup and uses the raw block device directly — ~3x faster swap throughput at the cost of plaintext secrets on the host filesystem. Adds docs/swap.md with full design, security analysis, benchmarks, kernel config requirements, and references to LinuxKit and other projects that informed the approach. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/guest-init/main.go | 27 ++++-- cmd/matchlock/cmd_run.go | 4 + docs/swap.md | 174 ++++++++++++++++++++++++++++++++++ pkg/api/config.go | 1 + pkg/sandbox/sandbox_darwin.go | 1 + pkg/sandbox/sandbox_linux.go | 1 + pkg/vm/backend.go | 1 + pkg/vm/darwin/backend.go | 3 + pkg/vm/linux/backend.go | 3 + 9 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 docs/swap.md diff --git a/cmd/guest-init/main.go b/cmd/guest-init/main.go index fe8629f..13a2cd7 100644 --- a/cmd/guest-init/main.go +++ b/cmd/guest-init/main.go @@ -76,7 +76,8 @@ type bootConfig struct { MTU int NoNetwork bool Disks []diskMount - SwapDevice string // e.g. "vde" — swapon /dev/vde + SwapDevice string // e.g. "vde" — swapon /dev/vde + EncryptSwap bool // use dm-crypt on swap device Overlay overlayBootConfig } @@ -142,7 +143,7 @@ func runInit() { fatal(err) } if cfg.SwapDevice != "" { - enableSwap(cfg.SwapDevice) + enableSwap(cfg.SwapDevice, cfg.EncryptSwap) } if cfg.Workspace != "" { @@ -228,6 +229,10 @@ func parseBootConfig(cmdlinePath string) (*bootConfig, error) { cfg.SwapDevice = v } + case strings.HasPrefix(field, "matchlock.encrypt_swap="): + v := strings.TrimPrefix(field, "matchlock.encrypt_swap=") + cfg.EncryptSwap = v == "1" || strings.EqualFold(v, "true") + case strings.HasPrefix(field, "matchlock.disk."): spec := strings.TrimPrefix(field, "matchlock.disk.") i := strings.IndexByte(spec, '=') @@ -682,15 +687,19 @@ func interfaceHasIPv4(name string) (bool, error) { return false, nil } -func enableSwap(device string) { +func enableSwap(device string, encrypt bool) { devPath := filepath.Join("/dev", device) - // Try encrypted swap via dm-crypt first. Falls back to unencrypted - // if dm-crypt is not available (kernel without CONFIG_DM_CRYPT). - swapDev, err := setupEncryptedSwap(devPath) - if err != nil { - warnf("encrypted swap setup failed, using unencrypted: %v", err) - swapDev = devPath + swapDev := devPath + if encrypt { + var err error + swapDev, err = setupEncryptedSwap(devPath) + if err != nil { + warnf("encrypted swap setup failed, using unencrypted: %v", err) + swapDev = devPath + } + } + if swapDev == devPath { if err := writeSwapHeader(swapDev); err != nil { warnf("format swap %s: %v", swapDev, err) return diff --git a/cmd/matchlock/cmd_run.go b/cmd/matchlock/cmd_run.go index f839990..4e625cf 100644 --- a/cmd/matchlock/cmd_run.go +++ b/cmd/matchlock/cmd_run.go @@ -125,6 +125,7 @@ func init() { runCmd.Flags().Int("timeout", api.DefaultTimeoutSeconds, "Timeout in seconds") runCmd.Flags().Int("disk-size", api.DefaultDiskSizeMB, "Disk size in MB") runCmd.Flags().Int("swap-size", 0, "Swap disk size in MB (0 = no swap)") + runCmd.Flags().Bool("encrypt-swap", true, "Encrypt swap with dm-crypt (ephemeral key, requires kernel CONFIG_DM_CRYPT)") runCmd.Flags().BoolP("detach", "d", false, "Run sandbox in detached mode (implies --rm=false; incompatible with -t/-i)") runCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY") runCmd.Flags().BoolP("interactive", "i", false, "Keep STDIN open") @@ -160,6 +161,7 @@ func init() { viper.BindPFlag("run.timeout", runCmd.Flags().Lookup("timeout")) viper.BindPFlag("run.disk-size", runCmd.Flags().Lookup("disk-size")) viper.BindPFlag("run.swap-size", runCmd.Flags().Lookup("swap-size")) + viper.BindPFlag("run.encrypt-swap", runCmd.Flags().Lookup("encrypt-swap")) viper.BindPFlag("run.detach", runCmd.Flags().Lookup("detach")) viper.BindPFlag("run.tty", runCmd.Flags().Lookup("tty")) viper.BindPFlag("run.interactive", runCmd.Flags().Lookup("interactive")) @@ -181,6 +183,7 @@ func runRun(cmd *cobra.Command, args []string) error { memory, _ := cmd.Flags().GetInt("memory") diskSize, _ := cmd.Flags().GetInt("disk-size") swapSize, _ := cmd.Flags().GetInt("swap-size") + encryptSwap, _ := cmd.Flags().GetBool("encrypt-swap") timeout, _ := cmd.Flags().GetInt("timeout") // Exec options @@ -399,6 +402,7 @@ func runRun(cmd *cobra.Command, args []string) error { MemoryMB: memory, DiskSizeMB: diskSize, SwapSizeMB: swapSize, + EncryptSwap: encryptSwap, TimeoutSeconds: timeout, }, Network: &api.NetworkConfig{ diff --git a/docs/swap.md b/docs/swap.md new file mode 100644 index 0000000..6cb5601 --- /dev/null +++ b/docs/swap.md @@ -0,0 +1,174 @@ +# Swap Support for Matchlock VMs + +## Overview + +Matchlock VMs run with a fixed memory allocation that macOS holds as wired +(non-reclaimable) host memory. When guest workloads exceed available RAM, +the Linux OOM killer terminates processes. The `--swap-size` flag attaches a +dedicated swap disk so the guest can page cold memory to disk instead of +OOM-killing. + +## Usage + +```bash +# 4GB RAM + 2GB swap +matchlock run --memory 4096 --swap-size 2048 --image ubuntu:22.04 -- ... + +# Disable encryption for maximum swap throughput (not recommended with secrets) +matchlock run --memory 4096 --swap-size 2048 --encrypt-swap=false --image ubuntu:22.04 -- ... +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--swap-size ` | 0 (disabled) | Size of the swap disk in MB. Creates a sparse file on the host. | +| `--encrypt-swap` | true | Encrypt swap with dm-crypt using an ephemeral random key. Requires `CONFIG_DM_CRYPT` in the guest kernel. Falls back to unencrypted if unavailable. | + +## How It Works + +### Host side + +1. A sparse file is created at `~/.matchlock/vms//swap.img` via + `Truncate()` (instant, no I/O). +2. The file is attached as an additional virtio block device. +3. The device name (e.g., `vdd`) is passed to the guest via kernel cmdline: + `matchlock.swap=vdd matchlock.encrypt_swap=1`. + +### Guest side (guest-init) + +When `matchlock.encrypt_swap=1`: + +1. Open `/dev/mapper/control` and create a dm-crypt device (`swap-crypt`) + using device-mapper ioctls. +2. Generate a random 256-bit key from `/dev/urandom`. +3. Load a crypt target: `aes-xts-plain64 0 /dev/vdd 0`. +4. Activate the device, producing `/dev/dm-0`. +5. Write a minimal swap header to `/dev/dm-0`. +6. Call `swapon` on `/dev/dm-0`. + +When `matchlock.encrypt_swap` is not set (or dm-crypt unavailable): + +1. Write a swap header directly to `/dev/vdd`. +2. Call `swapon` on `/dev/vdd`. + +The swap header is written in Go without `mkswap` — guest-init constructs the +`swap_header` struct and `SWAPSPACE2` magic directly, since `mkswap` is not +available in the minimal guest environment. + +### Key management + +The encryption key is generated from `/dev/urandom` at boot, passed to the +kernel's dm-crypt subsystem, and never written to disk. It exists only in +guest kernel memory. When the VM stops, the key is lost and the ciphertext +in `swap.img` becomes irrecoverable. + +## Security Considerations + +**With `--encrypt-swap` (default):** Secrets in guest memory that are paged +to swap are encrypted with AES-256-XTS before reaching the host filesystem. +The key is ephemeral — lost when the VM stops. Even if the host is +compromised while the VM is running, the key is in guest kernel memory, not +accessible from the host. + +**With `--encrypt-swap=false`:** Swap data is written in plaintext to the +host filesystem. Any secrets in guest memory (API keys, tokens, credentials) +that get swapped out are recoverable from `swap.img`. Use this only when +swap performance is critical and no secrets are present. + +**Cleanup:** The swap.img file is removed when the sandbox is cleaned up +(`matchlock kill` or `--rm`). If the matchlock process is killed +ungracefully, the file persists in `~/.matchlock/vms//swap.img`. + +## Performance + +Benchmarked on Apple M-series, 512MB RAM VM, 1GB swap, 800MB stress-ng +allocation (forces heavy swap thrashing): + +| Configuration | bogo ops/s | Overhead | +|---|---|---| +| No swap (in-RAM only, self-throttled) | 149,105 | baseline | +| Unencrypted swap | 2,043 | ~73x slower than RAM | +| Encrypted swap (AES-CE hardware) | 680 | ~3x slower than unencrypted | +| Encrypted swap (AES generic, no CE) | 213 | ~10x slower than unencrypted | + +**Notes:** +- The "no swap" baseline is not directly comparable — stress-ng throttled + its allocation to fit in RAM, so it wasn't doing any I/O. +- The relevant comparison is encrypted vs unencrypted swap: **~3x overhead** + with hardware AES, which is the expected cost of per-page encrypt/decrypt + through dm-crypt. +- Real workloads rarely thrash swap continuously. Most memory access hits + RAM; swap handles cold pages. The practical impact is much smaller than + the worst-case benchmark. +- ARM64 hardware AES acceleration (`CONFIG_CRYPTO_AES_ARM64_CE`) is + critical — without it, encryption is ~10x slower instead of ~3x. + +## Kernel Configuration + +The following kernel config options are required (added to both +`guest/kernel/arm64.config` and `guest/kernel/x86_64.config`): + +``` +# Device mapper and dm-crypt +CONFIG_MD=y +CONFIG_BLK_DEV_DM=y +CONFIG_DM_CRYPT=y + +# Cryptographic algorithms +CONFIG_CRYPTO=y +CONFIG_CRYPTO_AES=y +CONFIG_CRYPTO_XTS=y + +# ARM64 hardware AES acceleration (arm64.config only) +CONFIG_CRYPTO_AES_ARM64_CE=y +CONFIG_CRYPTO_AES_ARM64_CE_BLK=y +``` + +A kernel rebuild is required for encrypted swap to work. Without these +options, `--encrypt-swap` falls back to unencrypted swap with a warning. + +## Implementation Details + +### No external binaries + +Guest-init uses no external tools (`mkswap`, `cryptsetup`, etc.). Everything +is done via syscalls and ioctls: + +- **Swap header:** Written directly in Go — constructs the `swap_header` + struct per `include/linux/swap.h`. +- **dm-crypt:** Set up via device-mapper ioctls (`DM_DEV_CREATE`, + `DM_TABLE_LOAD`, `DM_DEV_SUSPEND`) against `/dev/mapper/control`. +- **Block device size:** Read via `BLKGETSIZE64` ioctl (since `Stat().Size()` + returns 0 for block devices). +- **swapon:** Called via `SYS_SWAPON` syscall. + +### Inspired by + +- **[LinuxKit](https://github.com/linuxkit/linuxkit)** (`pkg/swap/swap.sh`) — + the only other lightweight VM project with encrypted swap. Uses + `cryptsetup open --type plain --key-file /dev/urandom`. We replicate this + approach but use ioctls instead of shelling out to `cryptsetup`. +- **Linux `/etc/crypttab`** — the standard distro approach to encrypted swap + partitions with ephemeral keys from `/dev/urandom`. +- **[Firecracker](https://github.com/firecracker-microvm/firecracker)** — + default guest kernel configs include `CONFIG_SWAP=y` but no dm-crypt. +- **[Podman Machine](https://docs.podman.io/)** — uses zram (compressed RAM + swap) instead of disk-backed swap. A complementary approach that avoids + the encryption question entirely. + +### Files changed + +| File | Change | +|------|--------| +| `cmd/matchlock/cmd_run.go` | `--swap-size` and `--encrypt-swap` flags | +| `pkg/api/config.go` | `SwapSizeMB` and `EncryptSwap` on `Resources` | +| `pkg/vm/backend.go` | `SwapPath` and `EncryptSwap` on `VMConfig` | +| `pkg/sandbox/rootfs.go` | `createSwapImage()` — sparse file creation | +| `pkg/sandbox/sandbox_darwin.go` | Swap image creation, plumbing to VMConfig | +| `pkg/sandbox/sandbox_linux.go` | Same | +| `pkg/vm/darwin/backend.go` | Attach swap block device, kernel args | +| `pkg/vm/linux/backend.go` | Same for Firecracker | +| `cmd/guest-init/main.go` | Parse swap args, dm-crypt setup, swapon | +| `guest/kernel/arm64.config` | dm-crypt + AES-CE config options | +| `guest/kernel/x86_64.config` | dm-crypt config options | diff --git a/pkg/api/config.go b/pkg/api/config.go index d9871c7..ee92264 100644 --- a/pkg/api/config.go +++ b/pkg/api/config.go @@ -71,6 +71,7 @@ type Resources struct { MemoryMB int `json:"memory_mb,omitempty"` DiskSizeMB int `json:"disk_size_mb,omitempty"` SwapSizeMB int `json:"swap_size_mb,omitempty"` + EncryptSwap bool `json:"encrypt_swap,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` Timeout time.Duration `json:"-"` } diff --git a/pkg/sandbox/sandbox_darwin.go b/pkg/sandbox/sandbox_darwin.go index c7376e8..b00c60f 100644 --- a/pkg/sandbox/sandbox_darwin.go +++ b/pkg/sandbox/sandbox_darwin.go @@ -258,6 +258,7 @@ func New(ctx context.Context, config *api.Config, opts *Options) (sb *Sandbox, r PrebuiltRootfs: bootstrapRootfsPath, ExtraDisks: extraDisks, SwapPath: swapPath, + EncryptSwap: config.Resources != nil && config.Resources.EncryptSwap, DNSServers: config.Network.GetDNSServers(), Hostname: hostname, AddHosts: config.Network.AddHosts, diff --git a/pkg/sandbox/sandbox_linux.go b/pkg/sandbox/sandbox_linux.go index 7d87601..494b600 100644 --- a/pkg/sandbox/sandbox_linux.go +++ b/pkg/sandbox/sandbox_linux.go @@ -275,6 +275,7 @@ func New(ctx context.Context, config *api.Config, opts *Options) (sb *Sandbox, r Privileged: config.Privileged, ExtraDisks: extraDisks, SwapPath: swapPath, + EncryptSwap: config.Resources != nil && config.Resources.EncryptSwap, DNSServers: config.Network.GetDNSServers(), Hostname: hostname, AddHosts: config.Network.AddHosts, diff --git a/pkg/vm/backend.go b/pkg/vm/backend.go index 8ff4071..1299a9f 100644 --- a/pkg/vm/backend.go +++ b/pkg/vm/backend.go @@ -47,6 +47,7 @@ type VMConfig struct { PrebuiltRootfs string // Pre-prepared rootfs path (skips internal copy if set) ExtraDisks []DiskConfig // Additional block devices to attach SwapPath string // Host path to swap disk image (attached as virtio block device) + EncryptSwap bool // Use dm-crypt encryption on swap device } type Backend interface { diff --git a/pkg/vm/darwin/backend.go b/pkg/vm/darwin/backend.go index a7c7dbe..99f699b 100644 --- a/pkg/vm/darwin/backend.go +++ b/pkg/vm/darwin/backend.go @@ -247,6 +247,9 @@ func (b *DarwinBackend) buildKernelArgs(config *vm.VMConfig) string { if config.SwapPath != "" { diskArgs += fmt.Sprintf(" matchlock.swap=vd%c", devLetter) devLetter++ + if config.EncryptSwap { + diskArgs += " matchlock.encrypt_swap=1" + } } addHostArgs := "" diff --git a/pkg/vm/linux/backend.go b/pkg/vm/linux/backend.go index 0a6cb7f..fed032a 100644 --- a/pkg/vm/linux/backend.go +++ b/pkg/vm/linux/backend.go @@ -340,6 +340,9 @@ func (m *LinuxMachine) generateFirecrackerConfig() []byte { if m.config.SwapPath != "" { kernelArgs += fmt.Sprintf(" matchlock.swap=vd%c", devLetter) devLetter++ + if m.config.EncryptSwap { + kernelArgs += " matchlock.encrypt_swap=1" + } } for i, mapping := range m.config.AddHosts { kernelArgs += fmt.Sprintf(" matchlock.add_host.%d=%s,%s", i, mapping.Host, mapping.IP) From 366da12764fc1e02f4f69d003e6c05e1b07c4183 Mon Sep 17 00:00:00 2001 From: Anthony Sosso Date: Sun, 5 Apr 2026 10:33:29 -0700 Subject: [PATCH 5/5] feat(vm): add --zram-pct flag, SDK support, and acceptance tests Adds zram compressed swap support (CONFIG_ZRAM + CONFIG_LZO). Guest-init creates a zram0 device at the requested percentage of RAM, with priority 100 (higher than disk swap at -1). The kernel uses zram first for fast compressed swap, falling back to disk for overflow. Also adds --encrypt-swap flag (default true, opt-out with =false) and --zram-pct flag (default 0, opt-in). Both thread through the SDK via WithSwap(mb, encrypt) and WithZram(pct) builder methods. Fixes Config.Merge() to propagate SwapSizeMB, EncryptSwap, and ZramPct (was silently dropping them from SDK RPC requests). Acceptance tests (8 total, all passing): Tier 1 (stock kernel): - TestNoSwapByDefault: swap off when not requested - TestSwapDiskActive: swap device present, SwapTotal > 0 - TestSwapPreventsOOM: 800M alloc in 512M VM, pswpout > 0 Tier 2 (MATCHLOCK_DMCRYPT_KERNEL): - TestEncryptedSwapOnDmCrypt: swap on /dev/dm-*, not /dev/vd* - TestEncryptedSwapCiphertextOnHost: known string NOT in swap.img - TestZramCompressedSwap: /dev/zram0 active, compression ratio > 1.0 - TestZramPlusDiskSwapLayering: both active, zram priority > disk - TestUnencryptedSwapFlagDisablesEncryption: --encrypt-swap=false works Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/guest-init/main.go | 63 +++++++- cmd/matchlock/cmd_run.go | 4 + guest/kernel/arm64.config | 7 + guest/kernel/x86_64.config | 7 + pkg/api/config.go | 10 ++ pkg/sandbox/sandbox_common.go | 7 + pkg/sandbox/sandbox_darwin.go | 1 + pkg/sandbox/sandbox_linux.go | 1 + pkg/sdk/builder.go | 13 ++ pkg/sdk/create.go | 22 ++- pkg/sdk/types.go | 6 + pkg/vm/backend.go | 1 + pkg/vm/darwin/backend.go | 4 + pkg/vm/linux/backend.go | 3 + tests/acceptance/swap_test.go | 293 ++++++++++++++++++++++++++++++++++ 15 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 tests/acceptance/swap_test.go diff --git a/cmd/guest-init/main.go b/cmd/guest-init/main.go index 13a2cd7..a9f34e7 100644 --- a/cmd/guest-init/main.go +++ b/cmd/guest-init/main.go @@ -78,7 +78,8 @@ type bootConfig struct { Disks []diskMount SwapDevice string // e.g. "vde" — swapon /dev/vde EncryptSwap bool // use dm-crypt on swap device - Overlay overlayBootConfig + ZramPct int // percentage of RAM for zram swap (0 = disabled) + Overlay overlayBootConfig } type overlayBootConfig struct { @@ -142,6 +143,9 @@ func runInit() { if err := mountExtraDisks(cfg.Disks); err != nil { fatal(err) } + if cfg.ZramPct > 0 { + enableZram(cfg.ZramPct) + } if cfg.SwapDevice != "" { enableSwap(cfg.SwapDevice, cfg.EncryptSwap) } @@ -233,6 +237,13 @@ func parseBootConfig(cmdlinePath string) (*bootConfig, error) { v := strings.TrimPrefix(field, "matchlock.encrypt_swap=") cfg.EncryptSwap = v == "1" || strings.EqualFold(v, "true") + case strings.HasPrefix(field, "matchlock.zram_pct="): + v := strings.TrimPrefix(field, "matchlock.zram_pct=") + pct, convErr := strconv.Atoi(v) + if convErr == nil && pct > 0 && pct <= 100 { + cfg.ZramPct = pct + } + case strings.HasPrefix(field, "matchlock.disk."): spec := strings.TrimPrefix(field, "matchlock.disk.") i := strings.IndexByte(spec, '=') @@ -687,6 +698,56 @@ func interfaceHasIPv4(name string) (bool, error) { return false, nil } +// enableZram sets up a zram compressed swap device sized at half of total RAM. +// zram compresses pages in memory (~2-3x ratio with LZO), so a 3GB zram device +// on a 6GB VM can hold ~6-9GB of cold pages without any disk I/O. +// Runs with priority 100 so the kernel prefers zram over disk swap (priority -1). +// Silently skips if CONFIG_ZRAM is not in the kernel. +func enableZram(pct int) { + // Check if zram is available in this kernel. + if _, err := os.Stat("/sys/class/zram-control"); err != nil { + return + } + + // /dev/zram0 is auto-created when CONFIG_ZRAM=y (built-in). + if _, err := os.Stat("/dev/zram0"); err != nil { + return + } + + // Set compression algorithm (LZO is the fastest, good for swap). + os.WriteFile("/sys/block/zram0/comp_algorithm", []byte("lzo\n"), 0644) + + // Size zram as the requested percentage of total RAM. + var info unix.Sysinfo_t + if err := unix.Sysinfo(&info); err != nil { + warnf("zram: sysinfo failed: %v", err) + return + } + totalRAM := info.Totalram * uint64(info.Unit) + zramSize := totalRAM * uint64(pct) / 100 + if err := os.WriteFile("/sys/block/zram0/disksize", []byte(fmt.Sprintf("%d\n", zramSize)), 0644); err != nil { + warnf("zram: set disksize: %v", err) + return + } + + // Write swap header and enable. + if err := writeSwapHeader("/dev/zram0"); err != nil { + warnf("zram: write swap header: %v", err) + return + } + + pathBytes, err := unix.BytePtrFromString("/dev/zram0") + if err != nil { + return + } + // SWAP_FLAG_PREFER (0x8000) | priority 100 + const swapFlagPrefer = 0x8000 + _, _, errno := unix.Syscall(unix.SYS_SWAPON, uintptr(unsafe.Pointer(pathBytes)), swapFlagPrefer|100, 0) + if errno != 0 { + warnf("zram: swapon failed: %v", errno) + } +} + func enableSwap(device string, encrypt bool) { devPath := filepath.Join("/dev", device) diff --git a/cmd/matchlock/cmd_run.go b/cmd/matchlock/cmd_run.go index 4e625cf..6213dfd 100644 --- a/cmd/matchlock/cmd_run.go +++ b/cmd/matchlock/cmd_run.go @@ -126,6 +126,7 @@ func init() { runCmd.Flags().Int("disk-size", api.DefaultDiskSizeMB, "Disk size in MB") runCmd.Flags().Int("swap-size", 0, "Swap disk size in MB (0 = no swap)") runCmd.Flags().Bool("encrypt-swap", true, "Encrypt swap with dm-crypt (ephemeral key, requires kernel CONFIG_DM_CRYPT)") + runCmd.Flags().Int("zram-pct", 0, "Percentage of RAM to use for zram compressed swap (0 = disabled, requires kernel CONFIG_ZRAM)") runCmd.Flags().BoolP("detach", "d", false, "Run sandbox in detached mode (implies --rm=false; incompatible with -t/-i)") runCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY") runCmd.Flags().BoolP("interactive", "i", false, "Keep STDIN open") @@ -162,6 +163,7 @@ func init() { viper.BindPFlag("run.disk-size", runCmd.Flags().Lookup("disk-size")) viper.BindPFlag("run.swap-size", runCmd.Flags().Lookup("swap-size")) viper.BindPFlag("run.encrypt-swap", runCmd.Flags().Lookup("encrypt-swap")) + viper.BindPFlag("run.zram-pct", runCmd.Flags().Lookup("zram-pct")) viper.BindPFlag("run.detach", runCmd.Flags().Lookup("detach")) viper.BindPFlag("run.tty", runCmd.Flags().Lookup("tty")) viper.BindPFlag("run.interactive", runCmd.Flags().Lookup("interactive")) @@ -184,6 +186,7 @@ func runRun(cmd *cobra.Command, args []string) error { diskSize, _ := cmd.Flags().GetInt("disk-size") swapSize, _ := cmd.Flags().GetInt("swap-size") encryptSwap, _ := cmd.Flags().GetBool("encrypt-swap") + zramPct, _ := cmd.Flags().GetInt("zram-pct") timeout, _ := cmd.Flags().GetInt("timeout") // Exec options @@ -403,6 +406,7 @@ func runRun(cmd *cobra.Command, args []string) error { DiskSizeMB: diskSize, SwapSizeMB: swapSize, EncryptSwap: encryptSwap, + ZramPct: zramPct, TimeoutSeconds: timeout, }, Network: &api.NetworkConfig{ diff --git a/guest/kernel/arm64.config b/guest/kernel/arm64.config index 721e8fe..b40365c 100644 --- a/guest/kernel/arm64.config +++ b/guest/kernel/arm64.config @@ -192,6 +192,13 @@ CONFIG_POSIX_MQUEUE=y CONFIG_SPARSEMEM=y CONFIG_SPARSEMEM_VMEMMAP=y +# zram compressed swap +CONFIG_ZRAM=y +CONFIG_ZSMALLOC=y +CONFIG_LZO_COMPRESS=y +CONFIG_LZO_DECOMPRESS=y +CONFIG_CRYPTO_LZO=y + # Device mapper and dm-crypt (for encrypted swap) CONFIG_MD=y CONFIG_BLK_DEV_DM=y diff --git a/guest/kernel/x86_64.config b/guest/kernel/x86_64.config index 21e7523..11ea9d8 100644 --- a/guest/kernel/x86_64.config +++ b/guest/kernel/x86_64.config @@ -200,6 +200,13 @@ CONFIG_POSIX_MQUEUE=y CONFIG_SPARSEMEM=y CONFIG_SPARSEMEM_VMEMMAP=y +# zram compressed swap +CONFIG_ZRAM=y +CONFIG_ZSMALLOC=y +CONFIG_LZO_COMPRESS=y +CONFIG_LZO_DECOMPRESS=y +CONFIG_CRYPTO_LZO=y + # Device mapper and dm-crypt (for encrypted swap) CONFIG_MD=y CONFIG_BLK_DEV_DM=y diff --git a/pkg/api/config.go b/pkg/api/config.go index ee92264..15616fe 100644 --- a/pkg/api/config.go +++ b/pkg/api/config.go @@ -72,6 +72,7 @@ type Resources struct { DiskSizeMB int `json:"disk_size_mb,omitempty"` SwapSizeMB int `json:"swap_size_mb,omitempty"` EncryptSwap bool `json:"encrypt_swap,omitempty"` + ZramPct int `json:"zram_pct,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` Timeout time.Duration `json:"-"` } @@ -289,6 +290,15 @@ func (c *Config) Merge(other *Config) *Config { if other.Resources.DiskSizeMB > 0 { result.Resources.DiskSizeMB = other.Resources.DiskSizeMB } + if other.Resources.SwapSizeMB > 0 { + result.Resources.SwapSizeMB = other.Resources.SwapSizeMB + } + if other.Resources.EncryptSwap { + result.Resources.EncryptSwap = true + } + if other.Resources.ZramPct > 0 { + result.Resources.ZramPct = other.Resources.ZramPct + } if other.Resources.TimeoutSeconds > 0 { result.Resources.TimeoutSeconds = other.Resources.TimeoutSeconds } diff --git a/pkg/sandbox/sandbox_common.go b/pkg/sandbox/sandbox_common.go index 030e10a..d521aac 100644 --- a/pkg/sandbox/sandbox_common.go +++ b/pkg/sandbox/sandbox_common.go @@ -144,6 +144,13 @@ func shellSingleQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" } +func zramPct(config *api.Config) int { + if config.Resources == nil { + return 0 + } + return config.Resources.ZramPct +} + func flushGuestDisks(machine vm.Machine) { if machine == nil { return diff --git a/pkg/sandbox/sandbox_darwin.go b/pkg/sandbox/sandbox_darwin.go index b00c60f..0bee066 100644 --- a/pkg/sandbox/sandbox_darwin.go +++ b/pkg/sandbox/sandbox_darwin.go @@ -259,6 +259,7 @@ func New(ctx context.Context, config *api.Config, opts *Options) (sb *Sandbox, r ExtraDisks: extraDisks, SwapPath: swapPath, EncryptSwap: config.Resources != nil && config.Resources.EncryptSwap, + ZramPct: zramPct(config), DNSServers: config.Network.GetDNSServers(), Hostname: hostname, AddHosts: config.Network.AddHosts, diff --git a/pkg/sandbox/sandbox_linux.go b/pkg/sandbox/sandbox_linux.go index 494b600..abbe57d 100644 --- a/pkg/sandbox/sandbox_linux.go +++ b/pkg/sandbox/sandbox_linux.go @@ -276,6 +276,7 @@ func New(ctx context.Context, config *api.Config, opts *Options) (sb *Sandbox, r ExtraDisks: extraDisks, SwapPath: swapPath, EncryptSwap: config.Resources != nil && config.Resources.EncryptSwap, + ZramPct: zramPct(config), DNSServers: config.Network.GetDNSServers(), Hostname: hostname, AddHosts: config.Network.AddHosts, diff --git a/pkg/sdk/builder.go b/pkg/sdk/builder.go index fb23fc4..a97073e 100644 --- a/pkg/sdk/builder.go +++ b/pkg/sdk/builder.go @@ -56,6 +56,19 @@ func (b *SandboxBuilder) WithDiskSize(mb int) *SandboxBuilder { return b } +// WithSwap sets swap disk size in megabytes and encryption preference. +func (b *SandboxBuilder) WithSwap(mb int, encrypt bool) *SandboxBuilder { + b.opts.SwapSizeMB = mb + b.opts.EncryptSwap = encrypt + return b +} + +// WithZram sets the percentage of RAM to use for zram compressed swap. +func (b *SandboxBuilder) WithZram(pct int) *SandboxBuilder { + b.opts.ZramPct = pct + return b +} + // WithTimeout sets the maximum execution time in seconds. func (b *SandboxBuilder) WithTimeout(seconds int) *SandboxBuilder { b.opts.TimeoutSeconds = seconds diff --git a/pkg/sdk/create.go b/pkg/sdk/create.go index 31e61ab..55a258c 100644 --- a/pkg/sdk/create.go +++ b/pkg/sdk/create.go @@ -73,14 +73,22 @@ func (c *Client) Create(opts CreateOptions) (string, error) { startedNetworkHookServer = true } + resources := map[string]interface{}{ + "cpus": opts.CPUs, + "memory_mb": opts.MemoryMB, + "disk_size_mb": opts.DiskSizeMB, + "timeout_seconds": opts.TimeoutSeconds, + } + if opts.SwapSizeMB > 0 { + resources["swap_size_mb"] = opts.SwapSizeMB + resources["encrypt_swap"] = opts.EncryptSwap + } + if opts.ZramPct > 0 { + resources["zram_pct"] = opts.ZramPct + } params := map[string]interface{}{ - "image": opts.Image, - "resources": map[string]interface{}{ - "cpus": opts.CPUs, - "memory_mb": opts.MemoryMB, - "disk_size_mb": opts.DiskSizeMB, - "timeout_seconds": opts.TimeoutSeconds, - }, + "image": opts.Image, + "resources": resources, } if opts.KernelRef != "" { params["kernel"] = map[string]interface{}{"ref": opts.KernelRef} diff --git a/pkg/sdk/types.go b/pkg/sdk/types.go index 8da81a0..8a55a7b 100644 --- a/pkg/sdk/types.go +++ b/pkg/sdk/types.go @@ -21,6 +21,12 @@ type CreateOptions struct { MemoryMB int // DiskSizeMB is the disk size in megabytes (default: 5120) DiskSizeMB int + // SwapSizeMB is the swap disk size in megabytes (0 = disabled) + SwapSizeMB int + // EncryptSwap enables dm-crypt encryption on the swap device + EncryptSwap bool + // ZramPct is the percentage of RAM to use for zram compressed swap (0 = disabled) + ZramPct int // TimeoutSeconds is the maximum execution time TimeoutSeconds int // AllowedHosts is a list of allowed network hosts (supports wildcards) diff --git a/pkg/vm/backend.go b/pkg/vm/backend.go index 1299a9f..3b8488d 100644 --- a/pkg/vm/backend.go +++ b/pkg/vm/backend.go @@ -48,6 +48,7 @@ type VMConfig struct { ExtraDisks []DiskConfig // Additional block devices to attach SwapPath string // Host path to swap disk image (attached as virtio block device) EncryptSwap bool // Use dm-crypt encryption on swap device + ZramPct int // Percentage of RAM for zram compressed swap (0 = disabled) } type Backend interface { diff --git a/pkg/vm/darwin/backend.go b/pkg/vm/darwin/backend.go index 99f699b..076cf9d 100644 --- a/pkg/vm/darwin/backend.go +++ b/pkg/vm/darwin/backend.go @@ -252,6 +252,10 @@ func (b *DarwinBackend) buildKernelArgs(config *vm.VMConfig) string { } } + if config.ZramPct > 0 { + diskArgs += fmt.Sprintf(" matchlock.zram_pct=%d", config.ZramPct) + } + addHostArgs := "" for i, mapping := range config.AddHosts { addHostArgs += fmt.Sprintf(" matchlock.add_host.%d=%s,%s", i, mapping.Host, mapping.IP) diff --git a/pkg/vm/linux/backend.go b/pkg/vm/linux/backend.go index fed032a..2a21503 100644 --- a/pkg/vm/linux/backend.go +++ b/pkg/vm/linux/backend.go @@ -344,6 +344,9 @@ func (m *LinuxMachine) generateFirecrackerConfig() []byte { kernelArgs += " matchlock.encrypt_swap=1" } } + if m.config.ZramPct > 0 { + kernelArgs += fmt.Sprintf(" matchlock.zram_pct=%d", m.config.ZramPct) + } for i, mapping := range m.config.AddHosts { kernelArgs += fmt.Sprintf(" matchlock.add_host.%d=%s,%s", i, mapping.Host, mapping.IP) } diff --git a/tests/acceptance/swap_test.go b/tests/acceptance/swap_test.go new file mode 100644 index 0000000..2246c3c --- /dev/null +++ b/tests/acceptance/swap_test.go @@ -0,0 +1,293 @@ +//go:build acceptance + +package acceptance + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/jingkaihe/matchlock/pkg/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dmcryptKernel returns the path to a kernel with CONFIG_DM_CRYPT and +// CONFIG_ZRAM, or skips the test if MATCHLOCK_DMCRYPT_KERNEL is not set. +func dmcryptKernel(t *testing.T) string { + t.Helper() + path := os.Getenv("MATCHLOCK_DMCRYPT_KERNEL") + if path == "" { + t.Skip("MATCHLOCK_DMCRYPT_KERNEL not set; skipping test that requires dm-crypt/zram kernel") + } + if _, err := os.Stat(path); err != nil { + t.Skipf("MATCHLOCK_DMCRYPT_KERNEL=%s not found: %v", path, err) + } + return path +} + +// swapStateDir returns the VM state directory (~/.matchlock/vms//) for +// reading host-side files like swap.img. +func swapStateDir(vmID string) string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".matchlock", "vms", vmID) +} + +// --- Tier 1: Stock kernel tests (unencrypted swap) --- + +func TestNoSwapByDefault(t *testing.T) { + t.Parallel() + client := launchAlpine(t) + + result, err := client.Exec(context.Background(), "free -m") + require.NoError(t, err) + assert.Contains(t, result.Stdout, "Swap: 0") +} + +func TestSwapDiskActive(t *testing.T) { + t.Parallel() + client := launchWithBuilder(t, + sdk.New("alpine:latest").WithSwap(512, false), + ) + + // Swap should be on a virtio block device. + result, err := client.Exec(context.Background(), "cat /proc/swaps") + require.NoError(t, err) + assert.Contains(t, result.Stdout, "/dev/vd") + assert.Contains(t, result.Stdout, "partition") + + // Swap total should be non-zero. + result, err = client.Exec(context.Background(), "cat /proc/meminfo") + require.NoError(t, err) + for _, line := range strings.Split(result.Stdout, "\n") { + if strings.HasPrefix(line, "SwapTotal:") { + fields := strings.Fields(line) + require.Len(t, fields, 3) + kb, _ := strconv.Atoi(fields[1]) + assert.Greater(t, kb, 0, "SwapTotal should be > 0") + } + } +} + +func TestSwapPreventsOOM(t *testing.T) { + t.Parallel() + client := launchWithBuilder(t, + sdk.New("alpine:latest").WithMemory(512).WithSwap(1024, false), + ) + + // Install stress-ng, allocate 800M in a 512M VM. Without swap this + // would OOM-kill the process. + result, err := client.Exec(context.Background(), strings.Join([]string{ + "apk add --no-cache stress-ng >/dev/null 2>&1", + "stress-ng --vm 1 --vm-bytes 800M --vm-keep --timeout 10s --metrics-brief 2>&1", + "echo exit=$?", + }, " && ")) + require.NoError(t, err) + assert.Contains(t, result.Stdout, "exit=0", "stress-ng should succeed (swap prevents OOM)") + + // Confirm pages were actually swapped. + result, err = client.Exec(context.Background(), "cat /proc/vmstat") + require.NoError(t, err) + for _, line := range strings.Split(result.Stdout, "\n") { + if strings.HasPrefix(line, "pswpout ") { + fields := strings.Fields(line) + require.Len(t, fields, 2) + pswpout, _ := strconv.ParseInt(fields[1], 10, 64) + assert.Greater(t, pswpout, int64(0), "pswpout should be > 0: pages were swapped out") + } + } +} + +// --- Tier 2: dm-crypt kernel tests (encrypted swap + zram) --- +// These require MATCHLOCK_DMCRYPT_KERNEL pointing to a kernel built with +// CONFIG_DM_CRYPT, CONFIG_ZRAM, CONFIG_CRYPTO_AES, etc. + +func TestEncryptedSwapOnDmCrypt(t *testing.T) { + t.Parallel() + kernelPath := dmcryptKernel(t) + + client := launchWithBuilder(t, + sdk.New("alpine:latest"). + WithKernel("file://"+kernelPath). + WithSwap(512, true), + ) + + // Swap must be on /dev/dm-*, NOT on /dev/vd*. + result, err := client.Exec(context.Background(), "cat /proc/swaps") + require.NoError(t, err) + assert.Contains(t, result.Stdout, "/dev/dm-", + "encrypted swap should be on a dm-crypt device, got:\n%s", result.Stdout) + assert.NotContains(t, result.Stdout, "/dev/vd", + "encrypted swap should NOT fall back to raw device") +} + +func TestEncryptedSwapCiphertextOnHost(t *testing.T) { + // This test is CLI-based so we can read the host-side swap.img. + kernelPath := dmcryptKernel(t) + + // Start a detached VM with encrypted swap. + stdout, stderr, exitCode := runCLIWithTimeout(t, 2*time.Minute, + "run", "--image", "alpine:latest", + "--kernel", "file://"+kernelPath, + "--swap-size", "512", + "--encrypt-swap", + "-d", + ) + require.Equalf(t, 0, exitCode, "run failed: stdout=%s stderr=%s", stdout, stderr) + vmID := strings.TrimSpace(stdout) + require.True(t, strings.HasPrefix(vmID, "vm-"), "expected vm-* ID, got %q", vmID) + t.Cleanup(func() { + runCLI(t, "kill", vmID) + runCLI(t, "rm", vmID) + }) + + waitForDetachedVMExecReady(t, vmID) + + // Write a known secret pattern inside the guest. + secret := "MATCHLOCK_SWAP_ENCRYPTION_TEST_SECRET_7f3a9b2e" + execOut, _, execExit := runCLIWithTimeout(t, 30*time.Second, + "exec", vmID, "--", "sh", "-c", + fmt.Sprintf("yes '%s' | head -c 50000000 > /tmp/secret_data", secret)) + require.Equalf(t, 0, execExit, "write secret failed: %s", execOut) + + // Force the secret to swap by allocating memory under pressure. + execOut, _, execExit = runCLIWithTimeout(t, 60*time.Second, + "exec", vmID, "--", "sh", "-c", + "apk add --no-cache stress-ng >/dev/null 2>&1 && stress-ng --vm 1 --vm-bytes 450M --vm-keep --timeout 10s 2>&1; cat /proc/swaps") + require.Equalf(t, 0, execExit, "stress failed: %s", execOut) + assert.Contains(t, execOut, "/dev/dm-", "swap should be on dm-crypt device") + + // Read the host-side swap.img and search for the plaintext secret. + swapImg := filepath.Join(swapStateDir(vmID), "swap.img") + swapData, err := os.ReadFile(swapImg) + require.NoErrorf(t, err, "read swap.img at %s", swapImg) + assert.Greater(t, len(swapData), 0, "swap.img should not be empty") + + // The secret should NOT appear in the raw swap image (it's encrypted). + assert.NotContains(t, string(swapData), secret, + "plaintext secret found in encrypted swap.img — encryption is not working") +} + +func TestZramCompressedSwap(t *testing.T) { + t.Parallel() + kernelPath := dmcryptKernel(t) + + client := launchWithBuilder(t, + sdk.New("alpine:latest"). + WithKernel("file://"+kernelPath). + WithZram(50), + ) + + // zram0 should be active as swap. + result, err := client.Exec(context.Background(), "cat /proc/swaps") + require.NoError(t, err) + assert.Contains(t, result.Stdout, "/dev/zram0", + "zram should be active, got:\n%s", result.Stdout) + + // zram should have higher priority than disk swap. + for _, line := range strings.Split(result.Stdout, "\n") { + if strings.Contains(line, "/dev/zram0") { + fields := strings.Fields(line) + require.GreaterOrEqual(t, len(fields), 5) + priority, _ := strconv.Atoi(fields[4]) + assert.Greater(t, priority, 0, "zram priority should be positive (higher than disk)") + } + } + + // Allocate compressible memory (zeros) that exceeds RAM but fits in + // RAM + zram. Then verify zram actually compressed data. + result, err = client.Exec(context.Background(), strings.Join([]string{ + "dd if=/dev/zero of=/dev/shm/zeros bs=1M count=200 2>/dev/null", + "cat /sys/block/zram0/mm_stat", + }, " && ")) + require.NoError(t, err) + + // mm_stat fields: orig_data_size compr_data_size mem_used_total ... + fields := strings.Fields(strings.TrimSpace(result.Stdout)) + if len(fields) >= 2 { + origSize, _ := strconv.ParseInt(fields[0], 10, 64) + comprSize, _ := strconv.ParseInt(fields[1], 10, 64) + if origSize > 0 && comprSize > 0 { + ratio := float64(origSize) / float64(comprSize) + t.Logf("zram compression: %d bytes -> %d bytes (%.1fx ratio)", origSize, comprSize, ratio) + assert.Greater(t, ratio, 1.0, + "zram should compress data (ratio > 1.0)") + } + } +} + +func TestZramPlusDiskSwapLayering(t *testing.T) { + t.Parallel() + kernelPath := dmcryptKernel(t) + + client := launchWithBuilder(t, + sdk.New("alpine:latest"). + WithKernel("file://"+kernelPath). + WithMemory(512). + WithSwap(1024, true). + WithZram(50), + ) + + result, err := client.Exec(context.Background(), "cat /proc/swaps") + require.NoError(t, err) + + // Both zram and disk swap should be present. + assert.Contains(t, result.Stdout, "/dev/zram0", "zram should be active") + assert.Contains(t, result.Stdout, "/dev/dm-", "encrypted disk swap should be active") + + // Verify priority ordering: zram > disk. + var zramPriority, diskPriority int + for _, line := range strings.Split(result.Stdout, "\n") { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + pri, _ := strconv.Atoi(fields[4]) + if strings.Contains(line, "/dev/zram0") { + zramPriority = pri + } + if strings.Contains(line, "/dev/dm-") { + diskPriority = pri + } + } + assert.Greater(t, zramPriority, diskPriority, + "zram priority (%d) should be higher than disk priority (%d)", zramPriority, diskPriority) + + // Under memory pressure, pages should go to zram first (fast), + // then overflow to disk (slow). Verify both get used. + result, err = client.Exec(context.Background(), strings.Join([]string{ + "apk add --no-cache stress-ng >/dev/null 2>&1", + "stress-ng --vm 1 --vm-bytes 800M --vm-keep --timeout 10s 2>&1", + "cat /proc/swaps", + "cat /sys/block/zram0/mm_stat", + }, " && ")) + require.NoError(t, err) + + // After stress, both swap devices should show usage. + assert.Contains(t, result.Stdout, "/dev/zram0") + assert.Contains(t, result.Stdout, "/dev/dm-") +} + +func TestUnencryptedSwapFlagDisablesEncryption(t *testing.T) { + t.Parallel() + kernelPath := dmcryptKernel(t) + + client := launchWithBuilder(t, + sdk.New("alpine:latest"). + WithKernel("file://"+kernelPath). + WithSwap(512, false), // encrypt=false + ) + + // Swap should be on raw /dev/vd*, NOT /dev/dm-*. + result, err := client.Exec(context.Background(), "cat /proc/swaps") + require.NoError(t, err) + assert.Contains(t, result.Stdout, "/dev/vd", + "unencrypted swap should be on raw device") + assert.NotContains(t, result.Stdout, "/dev/dm-", + "--encrypt-swap=false should not use dm-crypt") +}