Repository: lima-vm/lima

Issue:
vz: Instance fail to start if disk size is not aligned to 512 bytes
### Description

Since we replaced qemu-img when creating or resizing disk images in raw format (#3180), it is possible to create disk that cannot be used by the virtualization framework.

## Reproduce

1. Create disk with unaligned size
```console
% _output/bin/limactl disk create test --size 123456789 --format raw
INFO[0000] Creating raw disk "test" with size 117.7MiB  
```

2. When we query the disk with qemu-img info, the disk size is rounded up to 512 bytes:

```console
% qemu-img info ~/.lima/_disks/test/datadisk
image: /Users/nir/.lima/_disks/test/datadisk
file format: raw
virtual size: 118 MiB (123457024 bytes)
disk size: 0 B
Child node '/file':
    filename: /Users/nir/.lima/_disks/test/datadisk
    protocol type: file
    file length: 118 MiB (123457024 bytes)
    disk size: 0 B
```

This is the way qemu block layer handlers disk images with unaligned size. It cannot use the actual size since the kernel block layer works in 512 bytes blocks. It cannot round down to 512 bytes since this will drop user data. The only way to work with this disk is to round the value up.

3. Create lima yaml:
```yaml
vmType: vz
images:
- location: "https://cloud-images.ubuntu.com/releases/24.10/release/ubuntu-24.10-server-cloudimg-arm64.img"
  arch: aarch64
cpus: 1
memory: 1g
plain: true
additionalDisks:
- name: test
  format: false
```

4. Create instance
```
% _output/bin/limactl create test.yaml 
? Creating an instance "test" Proceed with the current configuration
INFO[0003] Attempting to download the image              arch=aarch64 digest= location="https://cloud-images.ubuntu.com/releases/24.10/release/ubuntu-24.10-server-cloudimg-arm64.img"
INFO[0003] Using cache "/Users/nir/Library/Caches/lima/download/by-url-sha256/fe0930aca80e74ef9bcdc6e883fd6d716f490f765c8848d90f1d9c9cf69c43b2/data" 
INFO[0003] Converting "/Users/nir/.lima/test/basedisk" (qcow2) to a raw disk "/Users/nir/.lima/test/diffdisk" 
3.50 GiB / 3.50 GiB [---------------------------------------] 100.00% 1.71 GiB/s
INFO[0006] Expanding to 100GiB                          
INFO[0006] Run `limactl start test` to start the instance. 
```

5. Start the instance
```console
% _output/bin/limactl start test                         
INFO[0000] Using the existing instance "test"           
INFO[0000] Starting the instance "test" with VM driver "vz" 
INFO[0000] [hostagent] hostagent socket created at /Users/nir/.lima/test/ha.sock 
INFO[0000] [hostagent] Starting VZ (hint: to watch the boot progress, see "/Users/nir/.lima/test/serial*.log") 
INFO[0000] [hostagent] Mounting disk "test" on "/mnt/lima-test" 
INFO[0000] [hostagent] Converting "/Users/nir/.lima/_disks/test/datadisk" (raw) to a raw disk "/Users/nir/.lima/_disks/test/datadisk" 
FATA[0000] exiting, status={Running:false Degraded:false Exiting:true Errors:[] SSHLocalPort:0} (hint: see "/Users/nir/.lima/test/ha.stderr.log") 
```

6. host agent logs:
```console
% tail -1 ~/.lima/test/ha.stderr.log 
{"level":"fatal","msg":"failed to create disk attachment for extra disk \"/Users/nir/.lima/_disks/test/datadisk\": Error Domain=VZErrorDomain Code=5 Description=\"Invalid disk image. The disk image format is not recognized.\" UserInfo={\n    NSLocalizedFailure = \"Invalid disk image.\";\n    NSLocalizedFailureReason = \"The disk image format is not recognized.\";\n}","time":"2025-03-30T20:17:38+03:00"}
```

## Root cause

Apple virtualization framework cannot work with disk size that is not aligned to 512 bytes.

Before #3180 we used qemu-img create to create the image. Like qemu-img info, it rounds the size up to 512 bytes:

```console
% qemu-img create -f raw test.img 123456789
Formatting 'test.img', fmt=raw size=123456789

% ls -l test.img 
-rw-r--r--  1 nir  staff  123457024 Mar 30 20:31 test.img

% qemu-img info test.img 
image: test.img
file format: raw
virtual size: 118 MiB (123457024 bytes)
disk size: 16 KiB
Child node '/file':
    filename: test.img
    protocol type: file
    file length: 118 MiB (123457024 bytes)
    disk size: 16 KiB
```

## Fix

- round disk size up on input
- same when resizing disk

Discussion:

Solution patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a2b929cc..f322f4c3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -39,7 +39,7 @@ jobs:
     - name: Verify generated files
       run: make install-tools generate check-generated
     - name: Run golangci-lint
-      uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373  # v7.0.0
+      uses: golangci/golangci-lint-action@a5307c8f68bcd21ff73283f8629eb09caa60d7a7  # v7.0.0
       with:
         version: v2.0.1
         args: --verbose
@@ -50,7 +50,7 @@ jobs:
         sudo apt-get update
         sudo apt-get install -y shellcheck
     - name: Run file and directory name linter
-      uses: ls-lint/action@1887e6c0e7f2dfa81a2d67591f0eb7782720026f  # v2.2.3
+      uses: ls-lint/action@acc185de566968dd9b3ffc1b79dfc86e22306442  # v2.3.0
     - name: Run shellcheck
       run: find . -name '*.sh' | xargs shellcheck
     - name: Install shfmt
diff --git a/pkg/nativeimgutil/nativeimgutil.go b/pkg/nativeimgutil/nativeimgutil.go
index 733f2dc5..64308000 100644
--- a/pkg/nativeimgutil/nativeimgutil.go
+++ b/pkg/nativeimgutil/nativeimgutil.go
@@ -23,6 +23,17 @@ import (
 	"github.com/sirupsen/logrus"
 )
 
+// Disk image size must be aigned to sector size. Qemu block layer is rounding
+// up the size to 512 bytes. Apple virtualization framework reject disks not
+// aligned to 512 bytes.
+const sectorSize = 512
+
+// RoundUp rounds size up to sectorSize.
+func RoundUp(size int) int {
+	sectors := (size + sectorSize - 1) / sectorSize
+	return sectors * sectorSize
+}
+
 // CreateRawDataDisk creates an empty raw data disk.
 func CreateRawDataDisk(dir string, size int) error {
 	dataDisk := filepath.Join(dir, filenames.DataDisk)
@@ -34,13 +45,15 @@ func CreateRawDataDisk(dir string, size int) error {
 		return err
 	}
 	defer f.Close()
-	return f.Truncate(int64(size))
+	roundedSize := RoundUp(size)
+	return f.Truncate(int64(roundedSize))
 }
 
 // ResizeRawDataDisk resizes a raw data disk.
 func ResizeRawDataDisk(dir string, size int) error {
 	dataDisk := filepath.Join(dir, filenames.DataDisk)
-	return os.Truncate(dataDisk, int64(size))
+	roundedSize := RoundUp(size)
+	return os.Truncate(dataDisk, int64(roundedSize))
 }
 
 // ConvertToRaw converts a source disk into a raw disk.
diff --git a/pkg/qemu/qemu.go b/pkg/qemu/qemu.go
index f0a92c37..f2fab3a4 100644
--- a/pkg/qemu/qemu.go
+++ b/pkg/qemu/qemu.go
@@ -13,7 +13,6 @@ import (
 	"io/fs"
 	"os"
 	"os/exec"
-	"os/user"
 	"path/filepath"
 	"regexp"
 	"runtime"
@@ -1006,16 +1005,16 @@ func FindVirtiofsd(qemuExe string) (string, error) {
 		Binary      string `json:"binary"`
 	}
 
-	currentUser, err := user.Current()
+	homeDir, err := os.UserHomeDir()
 	if err != nil {
 		return "", err
 	}
 
 	const relativePath = "share/qemu/vhost-user"
 
-	binDir := filepath.Dir(qemuExe)                              // "/usr/local/bin"
-	usrDir := filepath.Dir(binDir)                               // "/usr/local"
-	userLocalDir := filepath.Join(currentUser.HomeDir, ".local") // "$HOME/.local"
+	binDir := filepath.Dir(qemuExe)                  // "/usr/local/bin"
+	usrDir := filepath.Dir(binDir)                   // "/usr/local"
+	userLocalDir := filepath.Join(homeDir, ".local") // "$HOME/.local"
 
 	candidates := []string{
 		filepath.Join(userLocalDir, relativePath),
@@ -1175,14 +1174,14 @@ func getFirmware(qemuExe string, arch limayaml.Arch) (string, error) {
 		return "", fmt.Errorf("unexpected architecture: %q", arch)
 	}
 
-	currentUser, err := user.Current()
+	homeDir, err := os.UserHomeDir()
 	if err != nil {
 		return "", err
 	}
 
-	binDir := filepath.Dir(qemuExe)                              // "/usr/local/bin"
-	localDir := filepath.Dir(binDir)                             // "/usr/local"
-	userLocalDir := filepath.Join(currentUser.HomeDir, ".local") // "$HOME/.local"
+	binDir := filepath.Dir(qemuExe)                  // "/usr/local/bin"
+	localDir := filepath.Dir(binDir)                 // "/usr/local"
+	userLocalDir := filepath.Join(homeDir, ".local") // "$HOME/.local"
 
 	relativePath := fmt.Sprintf("share/qemu/edk2-%s-code.fd", qemuEdk2Arch(arch))
 	relativePathWin := fmt.Sprintf("share/edk2-%s-code.fd", qemuEdk2Arch(arch))
@@ -1241,14 +1240,14 @@ func getFirmwareVars(qemuExe string, arch limayaml.Arch) (string, error) {
 		return "", fmt.Errorf("unexpected architecture: %q", arch)
 	}
 
-	currentUser, err := user.Current()
+	homeDir, err := os.UserHomeDir()
 	if err != nil {
 		return "", err
 	}
 
-	binDir := filepath.Dir(qemuExe)                              // "/usr/local/bin"
-	localDir := filepath.Dir(binDir)                             // "/usr/local"
-	userLocalDir := filepath.Join(currentUser.HomeDir, ".local") // "$HOME/.local"
+	binDir := filepath.Dir(qemuExe)                  // "/usr/local/bin"
+	localDir := filepath.Dir(binDir)                 // "/usr/local"
+	userLocalDir := filepath.Join(homeDir, ".local") // "$HOME/.local"
 
 	relativePath := fmt.Sprintf("share/qemu/edk2-%s-vars.fd", qemuEdk2Arch(targetArch))
 	relativePathWin := fmt.Sprintf("share/edk2-%s-vars.fd", qemuEdk2Arch(targetArch))
diff --git a/pkg/store/instance.go b/pkg/store/instance.go
index 2ddd4380..2af8f814 100644
--- a/pkg/store/instance.go
+++ b/pkg/store/instance.go
@@ -9,7 +9,6 @@ import (
 	"fmt"
 	"io"
 	"os"
-	"os/user"
 	"path/filepath"
 	"runtime"
 	"strconv"
@@ -355,11 +354,10 @@ func PrintInstances(w io.Writer, instances []*Instance, format string, options *
 		}
 		fmt.Fprintln(w)
 
-		u, err := user.Current()
+		homeDir, err := os.UserHomeDir()
 		if err != nil {
 			return err
 		}
-		homeDir := u.HomeDir
 
 		for _, instance := range instances {
 			dir := instance.Dir
diff --git a/website/content/en/docs/config/network/user.md b/website/content/en/docs/config/network/user.md
index 1dd23fde..24d8859c 100644
--- a/website/content/en/docs/config/network/user.md
+++ b/website/content/en/docs/config/network/user.md
@@ -7,7 +7,7 @@ weight: 30
 By default Lima only enables the user-mode networking aka "slirp".
 
 The subnet is hard-coded to `192.168.5.0/24`.
-Use [`user-v2`]]({{< ref "/docs/config/network/user-v2" >}}) network to customize the subnet.
+Use [`user-v2`]({{< ref "/docs/config/network/user-v2" >}}) network to customize the subnet.
 
 ## Guest IP (192.168.5.15)
 



Test patch:
diff --git a/pkg/nativeimgutil/nativeimgutil_test.go b/pkg/nativeimgutil/nativeimgutil_test.go
index 71c4d4d1..42ce10da 100644
--- a/pkg/nativeimgutil/nativeimgutil_test.go
+++ b/pkg/nativeimgutil/nativeimgutil_test.go
@@ -13,6 +13,24 @@ import (
 	"gotest.tools/v3/assert"
 )
 
+func TestRoundUp(t *testing.T) {
+	tests := []struct {
+		Size    int
+		Rounded int
+	}{
+		{0, 0},
+		{1, 512},
+		{511, 512},
+		{512, 512},
+		{123456789, 123457024},
+	}
+	for _, tc := range tests {
+		if RoundUp(tc.Size) != tc.Rounded {
+			t.Errorf("expected %d, got %d", tc.Rounded, tc.Size)
+		}
+	}
+}
+
 func createImg(name, format, size string) error {
 	return exec.Command("qemu-img", "create", name, "-f", format, size).Run()
 }
diff --git a/pkg/store/instance_test.go b/pkg/store/instance_test.go
index b0b87c1c..52a189e3 100644
--- a/pkg/store/instance_test.go
+++ b/pkg/store/instance_test.go
@@ -5,7 +5,7 @@ package store
 
 import (
 	"bytes"
-	"os/user"
+	"os"
 	"path/filepath"
 	"runtime"
 	"strings"
@@ -85,10 +85,10 @@ func TestPrintInstanceTableEmu(t *testing.T) {
 
 func TestPrintInstanceTableHome(t *testing.T) {
 	var buf bytes.Buffer
-	u, err := user.Current()
+	homeDir, err := os.UserHomeDir()
 	assert.NilError(t, err)
 	instance1 := instance
-	instance1.Dir = filepath.Join(u.HomeDir, "dir")
+	instance1.Dir = filepath.Join(homeDir, "dir")
 	instances := []*Instance{&instance1}
 	err = PrintInstances(&buf, instances, "table", nil)
 	assert.NilError(t, err)