Repository: lima-vm/lima

Issue:
`limactl copy` should use `rsync` instead of `scp` for copying a directory
### Description

`limactl copy` should use `rsync` (present on macOS by default) instead of `scp` for copying a directory.
`scp` might be still faster for small directories, but probably the overhead is subtle.

https://github.com/lima-vm/lima/blob/eebc6847fb8f5d55e489fdc2948c6acafc2d5436/cmd/limactl/copy.go#L47



Discussion:
@afbjorklund: You could make it optional, like we ended up doing for `docker-machine scp`?

```
Usage: docker-machine scp [OPTIONS] [arg...]

Copy files between machines

Description:
   Arguments are [[user@]machine:][path] [[user@]machine:][path].

Options:
   
   --recursive, -r	Copy files recursively (required to copy directories)
   --delta, -d		Reduce amount of data sent over network by sending only the differences (uses rsync)
   --quiet, -q		Disables the progress meter as well as warning and diagnostic messages from ssh
```

(my suggestion was `--rsync`, since it does has some rather tricky semantics)

Main reason is that it would require to install `rsync` on the guest as well...@ayushgml: @AkihiroSuda Can I work on this?@afbjorklund: I found that I had to add --progress, to make the rsync output more similar to scp default output:

https://github.com/lima-vm/lima/pull/2028/commits/a63be723430d03a1c644a85986d6cc97d5b83dd6@AkihiroSuda: > @AkihiroSuda Can I work on this?

Of course.@olamilekan000: is this still open to be worked on? @AkihiroSuda 
@kairveeehh: is this an open or closed issue?@jandubois: > is this an open or closed issue?

It is an open issue. It looks like it is waiting for #3143, which is still work in progress.

Solution patch:
diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml
index 9455a8fb..83995fec 100644
--- a/.github/workflows/codeql.yaml
+++ b/.github/workflows/codeql.yaml
@@ -32,7 +32,7 @@ jobs:
       uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@d3678e237b9c32a6c9bffb3315c335f976f3549f  # v3.30.2
+      uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3  # v3.30.3
       with:
         languages: ${{ matrix.language }}
         build-mode: ${{ matrix.build-mode }}
@@ -48,6 +48,6 @@ jobs:
         exit 1
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@d3678e237b9c32a6c9bffb3315c335f976f3549f  # v3.30.2
+      uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3  # v3.30.3
       with:
         category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index b90e31b3..59962110 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -60,6 +60,6 @@ jobs:
     # Upload the results to GitHub's code scanning dashboard (optional).
     # Commenting out will disable upload of results to your repo's Code Scanning dashboard
     - name: "Upload to code-scanning"
-      uses: github/codeql-action/upload-sarif@d3678e237b9c32a6c9bffb3315c335f976f3549f  # v3.30.2
+      uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3  # v3.30.3
       with:
         sarif_file: results.sarif
diff --git a/Makefile b/Makefile
index 90af0241..7c3bf358 100644
--- a/Makefile
+++ b/Makefile
@@ -43,16 +43,38 @@ PACKAGE := github.com/lima-vm/lima/v2
 VERSION := $(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags)
 VERSION_TRIMMED := $(VERSION:v%=%)
 
+# `DEBUG` flag to build binaries with debug information for use by `dlv exec`.
+# This implies KEEP_DWARF=1 and KEEP_SYMBOLS=1.
+DEBUG ?=
+GO_BUILD_GCFLAGS ?=
+KEEP_DWARF ?=
 KEEP_SYMBOLS ?=
+ifeq ($(DEBUG),1)
+	# Disable optimizations and inlining to make debugging easier.
+	GO_BUILD_GCFLAGS = -gcflags="all=-N -l"
+	# Keep the symbol table
+	KEEP_DWARF = 1
+	# Enable DWARF generation
+	KEEP_SYMBOLS = 1
+endif
+
+GO_BUILD_LDFLAGS_W := true
+ifeq ($(KEEP_DWARF),1)
+	GO_BUILD_LDFLAGS_W = false
+endif
+
 GO_BUILD_LDFLAGS_S := true
 ifeq ($(KEEP_SYMBOLS),1)
 	GO_BUILD_LDFLAGS_S = false
 endif
-GO_BUILD_LDFLAGS := -ldflags="-s=$(GO_BUILD_LDFLAGS_S) -w -X $(PACKAGE)/pkg/version.Version=$(VERSION)"
+# `-s`: Strip the symbol table according to the KEEP_SYMBOLS config
+# `-w`: Disable DWARF generation according to the KEEP_DWARF config
+# `-X`: Embed version information.
+GO_BUILD_LDFLAGS := -ldflags="-s=$(GO_BUILD_LDFLAGS_S) -w=$(GO_BUILD_LDFLAGS_W) -X $(PACKAGE)/pkg/version.Version=$(VERSION)"
 # `go -version -m` returns -tags with comma-separated list, because space-separated list is deprecated in go1.13.
 # converting to comma-separated list is useful for comparing with the output of `go version -m`.
 GO_BUILD_FLAG_TAGS := $(addprefix -tags=,$(shell echo "$(GO_BUILDTAGS)"|tr " " "\n"|paste -sd "," -))
-GO_BUILD := $(GO) build $(GO_BUILD_LDFLAGS) $(GO_BUILD_FLAG_TAGS)
+GO_BUILD := $(strip $(GO) build $(GO_BUILD_GCFLAGS) $(GO_BUILD_LDFLAGS) $(GO_BUILD_FLAG_TAGS))
 
 ################################################################################
 # Features
@@ -78,7 +100,9 @@ help-variables:
 	@echo  '# Variables that can be overridden.'
 	@echo
 	@echo  '- PREFIX       (directory)  : Installation prefix (default: /usr/local)'
+	@echo  '- KEEP_DWARF   (1 or 0)     : Whether to keep DWARF information (default: 0)'
 	@echo  '- KEEP_SYMBOLS (1 or 0)     : Whether to keep symbols (default: 0)'
+	@echo  '- DEBUG        (1 or 0)     : Whether to build with debug information (default: 0)'
 
 .PHONY: help-targets
 help-targets:
@@ -177,7 +201,7 @@ dependencies_for_cmd = go.mod $(call find_files_excluding_dir_and_test, ./cmd/$(
 # $(1): target binary
 extract_build_vars = $(shell \
 	($(GO) version -m $(1) 2>&- || echo $(1):) | \
-	awk 'FNR==1{print "GOVERSION="$$2}$$2~/^(CGO|GO|-ldflags|-tags).*=.+$$/{sub("^.*"$$2,$$2); print $$0}' \
+	awk 'FNR==1{print "GOVERSION="$$2}$$2~/^(CGO|GO|-gcflags|-ldflags|-tags).*=.+$$/{sub("^.*"$$2,$$2); print $$0}' \
 )
 
 # a list of keys from the GO build variables to be used for calling `go env`.
@@ -192,7 +216,7 @@ go_build_vars = $(shell \
 	$(ENVS_$(1)) $(GO) env $(2) | \
 	awk '/ /{print "\""$$0"\""; next}{print}' | \
 	for k in $(2); do read -r v && echo "$$k=$${v}"; done \
-) $(GO_BUILD_LDFLAGS) $(GO_BUILD_FLAG_TAGS)
+) $(GO_BUILD_GCFLAGS) $(GO_BUILD_LDFLAGS) $(GO_BUILD_FLAG_TAGS)
 
 # returns the difference between $(1) and $(2).
 diff = $(filter-out $(2),$(1))$(filter-out $(1),$(2))
@@ -495,7 +519,11 @@ uninstall:
 		"$(DEST)/bin/kubectl.lima" \
 		"$(DEST)/share/man/man1/lima.1" \
 		"$(DEST)/share/man/man1/limactl"*".1" \
-		"$(DEST)/share/lima" "$(DEST)/share/doc/lima"
+		"$(DEST)/share/lima" \
+		"$(DEST)/share/doc/lima" \
+		"$(DEST)/libexec/lima/lima-driver-qemu$(exe)" \
+		"$(DEST)/libexec/lima/lima-driver-vz$(exe)" \
+		"$(DEST)/libexec/lima/lima-driver-wsl2$(exe)"
 	if [ "$$(readlink "$(DEST)/bin/nerdctl")" = "nerdctl.lima" ]; then rm "$(DEST)/bin/nerdctl"; fi
 	if [ "$$(readlink "$(DEST)/bin/apptainer")" = "apptainer.lima" ]; then rm "$(DEST)/bin/apptainer"; fi
 
diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go
index 9d7a0055..8f3ac550 100644
--- a/cmd/limactl/copy.go
+++ b/cmd/limactl/copy.go
@@ -4,6 +4,7 @@
 package main
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"os"
@@ -26,11 +27,45 @@ const copyHelp = `Copy files between host and guest
 
 Prefix guest filenames with the instance name and a colon.
 
-Example: limactl copy default:/etc/os-release .
+Backends:
+  auto   - Automatically selects the best available backend (rsync preferred, falls back to scp)
+  rsync  - Uses rsync for faster transfers with resume capability (requires rsync on both host and guest)
+  scp    - Uses scp for reliable transfers (always available)
+
+Examples:
+  # Copy file from guest to host (auto backend)
+  limactl copy default:/etc/os-release .
+
+  # Copy file from host to guest with verbose output
+  limactl copy -v myfile.txt default:/tmp/
+
+  # Copy directory recursively using rsync backend
+  limactl copy --backend=rsync -r ./mydir default:/tmp/
+
+  # Copy using scp backend specifically
+  limactl copy --backend=scp default:/var/log/app.log ./logs/
+
+  # Copy multiple files
+  limactl copy file1.txt file2.txt default:/tmp/
 
 Not to be confused with 'limactl clone'.
 `
 
+type copyTool string
+
+const (
+	rsync copyTool = "rsync"
+	scp   copyTool = "scp"
+	auto  copyTool = "auto"
+)
+
+type copyPath struct {
+	instanceName string
+	path         string
+	isRemote     bool
+	instance     *limatype.Instance
+}
+
 func newCopyCommand() *cobra.Command {
 	copyCommand := &cobra.Command{
 		Use:     "copy SOURCE ... TARGET",
@@ -44,6 +79,7 @@ func newCopyCommand() *cobra.Command {
 
 	copyCommand.Flags().BoolP("recursive", "r", false, "Copy directories recursively")
 	copyCommand.Flags().BoolP("verbose", "v", false, "Enable verbose output")
+	copyCommand.Flags().String("backend", "auto", "Copy backend (scp|rsync|auto)")
 
 	return copyCommand
 }
@@ -60,13 +96,6 @@ func copyAction(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	arg0, err := exec.LookPath("scp")
-	if err != nil {
-		return err
-	}
-	instances := make(map[string]*limatype.Instance)
-	scpFlags := []string{}
-	scpArgs := []string{}
 	debug, err := cmd.Flags().GetBool("debug")
 	if err != nil {
 		return err
@@ -76,62 +105,215 @@ func copyAction(cmd *cobra.Command, args []string) error {
 		verbose = true
 	}
 
-	if verbose {
-		scpFlags = append(scpFlags, "-v")
-	} else {
-		scpFlags = append(scpFlags, "-q")
+	copyPaths, err := parseCopyArgs(ctx, args)
+	if err != nil {
+		return err
 	}
 
-	if recursive {
-		scpFlags = append(scpFlags, "-r")
+	backend, err := cmd.Flags().GetString("backend")
+	if err != nil {
+		return err
+	}
+
+	cpTool, toolPath, err := selectCopyTool(ctx, copyPaths, backend)
+	if err != nil {
+		return err
+	}
+
+	logrus.Debugf("using copy tool %q", toolPath)
+
+	var copyCmd *exec.Cmd
+	switch cpTool {
+	case scp:
+		copyCmd, err = scpCommand(ctx, toolPath, copyPaths, verbose, recursive)
+	case rsync:
+		copyCmd, err = rsyncCommand(ctx, toolPath, copyPaths, verbose, recursive)
+	default:
+		err = fmt.Errorf("invalid copy tool %q", cpTool)
 	}
-	// this assumes that ssh and scp come from the same place, but scp has no -V
-	sshExe, err := sshutil.NewSSHExe()
 	if err != nil {
 		return err
 	}
-	legacySSH := sshutil.DetectOpenSSHVersion(ctx, sshExe).LessThan(*semver.New("8.0.0"))
+
+	copyCmd.Stdin = cmd.InOrStdin()
+	copyCmd.Stdout = cmd.OutOrStdout()
+	copyCmd.Stderr = cmd.ErrOrStderr()
+	logrus.Debugf("executing %v (may take a long time)", copyCmd)
+
+	// TODO: use syscall.Exec directly (results in losing tty?)
+	return copyCmd.Run()
+}
+
+func parseCopyArgs(ctx context.Context, args []string) ([]*copyPath, error) {
+	var copyPaths []*copyPath
+
 	for _, arg := range args {
+		cp := &copyPath{}
+
 		if runtime.GOOS == "windows" {
 			if filepath.IsAbs(arg) {
+				var err error
 				arg, err = ioutilx.WindowsSubsystemPath(ctx, arg)
 				if err != nil {
-					return err
+					return nil, err
 				}
 			} else {
 				arg = filepath.ToSlash(arg)
 			}
 		}
-		path := strings.Split(arg, ":")
-		switch len(path) {
+
+		parts := strings.SplitN(arg, ":", 2)
+		switch len(parts) {
 		case 1:
-			scpArgs = append(scpArgs, arg)
+			cp.path = arg
+			cp.isRemote = false
 		case 2:
-			instName := path[0]
-			inst, err := store.Inspect(ctx, instName)
+			cp.instanceName = parts[0]
+			cp.path = parts[1]
+			cp.isRemote = true
+
+			inst, err := store.Inspect(ctx, cp.instanceName)
 			if err != nil {
 				if errors.Is(err, os.ErrNotExist) {
-					return fmt.Errorf("instance %q does not exist, run `limactl create %s` to create a new instance", instName, instName)
+					return nil, fmt.Errorf("instance %q does not exist, run `limactl create %s` to create a new instance", cp.instanceName, cp.instanceName)
 				}
-				return err
+				return nil, err
 			}
 			if inst.Status == limatype.StatusStopped {
-				return fmt.Errorf("instance %q is stopped, run `limactl start %s` to start the instance", instName, instName)
+				return nil, fmt.Errorf("instance %q is stopped, run `limactl start %s` to start the instance", cp.instanceName, cp.instanceName)
 			}
+			cp.instance = inst
+		default:
+			return nil, fmt.Errorf("path %q contains multiple colons", arg)
+		}
+
+		copyPaths = append(copyPaths, cp)
+	}
+
+	return copyPaths, nil
+}
+
+func selectCopyTool(ctx context.Context, copyPaths []*copyPath, backend string) (copyTool, string, error) {
+	switch copyTool(backend) {
+	case scp:
+		scpPath, err := exec.LookPath("scp")
+		if err != nil {
+			return "", "", fmt.Errorf("scp not found on host: %w", err)
+		}
+		return scp, scpPath, nil
+	case rsync:
+		rsyncPath, err := exec.LookPath("rsync")
+		if err != nil {
+			return "", "", fmt.Errorf("rsync not found on host: %w", err)
+		}
+		if !rsyncAvailableOnGuests(ctx, copyPaths) {
+			return "", "", errors.New("rsync not available on guest(s)")
+		}
+		return rsync, rsyncPath, nil
+	case auto:
+		if rsyncPath, err := exec.LookPath("rsync"); err == nil {
+			if rsyncAvailableOnGuests(ctx, copyPaths) {
+				return rsync, rsyncPath, nil
+			}
+			logrus.Debugf("rsync not available on guest(s), falling back to scp")
+		} else {
+			logrus.Debugf("rsync not found on host, falling back to scp: %v", err)
+		}
+
+		scpPath, err := exec.LookPath("scp")
+		if err != nil {
+			return "", "", fmt.Errorf("neither rsync nor scp found on host: %w", err)
+		}
+		return scp, scpPath, nil
+	default:
+		return "", "", fmt.Errorf("invalid backend %q, must be one of: scp, rsync, auto", backend)
+	}
+}
+
+func rsyncAvailableOnGuests(ctx context.Context, copyPaths []*copyPath) bool {
+	instances := make(map[string]*limatype.Instance)
+
+	for _, cp := range copyPaths {
+		if cp.isRemote {
+			instances[cp.instanceName] = cp.instance
+		}
+	}
+
+	for instName, inst := range instances {
+		if !checkRsyncOnGuest(ctx, inst) {
+			logrus.Debugf("rsync not available on instance %q", instName)
+			return false
+		}
+	}
+
+	return true
+}
+
+func checkRsyncOnGuest(ctx context.Context, inst *limatype.Instance) bool {
+	sshExe, err := sshutil.NewSSHExe()
+	if err != nil {
+		logrus.Debugf("failed to create SSH executable: %v", err)
+		return false
+	}
+	sshOpts, err := sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false)
+	if err != nil {
+		logrus.Debugf("failed to get SSH options for rsync check: %v", err)
+		return false
+	}
+
+	sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
+	checkCmd := exec.CommandContext(ctx, "ssh")
+	checkCmd.Args = append(checkCmd.Args, sshArgs...)
+	checkCmd.Args = append(checkCmd.Args,
+		"-p", fmt.Sprintf("%d", inst.SSHLocalPort),
+		fmt.Sprintf("%s@127.0.0.1", *inst.Config.User.Name),
+		"command -v rsync >/dev/null 2>&1",
+	)
+
+	err = checkCmd.Run()
+	return err == nil
+}
+
+func scpCommand(ctx context.Context, command string, copyPaths []*copyPath, verbose, recursive bool) (*exec.Cmd, error) {
+	instances := make(map[string]*limatype.Instance)
+	scpFlags := []string{}
+	scpArgs := []string{}
+
+	if verbose {
+		scpFlags = append(scpFlags, "-v")
+	} else {
+		scpFlags = append(scpFlags, "-q")
+	}
+
+	if recursive {
+		scpFlags = append(scpFlags, "-r")
+	}
+
+	// this assumes that ssh and scp come from the same place, but scp has no -V
+	sshExeForVersion, err := sshutil.NewSSHExe()
+	if err != nil {
+		return nil, err
+	}
+	legacySSH := sshutil.DetectOpenSSHVersion(ctx, sshExeForVersion).LessThan(*semver.New("8.0.0"))
+
+	for _, cp := range copyPaths {
+		if cp.isRemote {
 			if legacySSH {
-				scpFlags = append(scpFlags, "-P", fmt.Sprintf("%d", inst.SSHLocalPort))
-				scpArgs = append(scpArgs, fmt.Sprintf("%s@127.0.0.1:%s", *inst.Config.User.Name, path[1]))
+				scpFlags = append(scpFlags, "-P", fmt.Sprintf("%d", cp.instance.SSHLocalPort))
+				scpArgs = append(scpArgs, fmt.Sprintf("%s@127.0.0.1:%s", *cp.instance.Config.User.Name, cp.path))
 			} else {
-				scpArgs = append(scpArgs, fmt.Sprintf("scp://%s@127.0.0.1:%d/%s", *inst.Config.User.Name, inst.SSHLocalPort, path[1]))
+				scpArgs = append(scpArgs, fmt.Sprintf("scp://%s@127.0.0.1:%d/%s", *cp.instance.Config.User.Name, cp.instance.SSHLocalPort, cp.path))
 			}
-			instances[instName] = inst
-		default:
-			return fmt.Errorf("path %q contains multiple colons", arg)
+			instances[cp.instanceName] = cp.instance
+		} else {
+			scpArgs = append(scpArgs, cp.path)
 		}
 	}
+
 	if legacySSH && len(instances) > 1 {
-		return errors.New("more than one (instance) host is involved in this command, this is only supported for openSSH v8.0 or higher")
+		return nil, errors.New("more than one (instance) host is involved in this command, this is only supported for openSSH v8.0 or higher")
 	}
+
 	scpFlags = append(scpFlags, "-3", "--")
 	scpArgs = append(scpFlags, scpArgs...)
 
@@ -143,32 +325,78 @@ func copyAction(cmd *cobra.Command, args []string) error {
 		for _, inst := range instances {
 			sshExe, err := sshutil.NewSSHExe()
 			if err != nil {
-				return err
+				return nil, err
 			}
 			sshOpts, err = sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false)
 			if err != nil {
-				return err
+				return nil, err
 			}
 		}
 	} else {
 		// Copying among multiple hosts; we can't pass in host-specific options.
 		sshExe, err := sshutil.NewSSHExe()
 		if err != nil {
-			return err
+			return nil, err
 		}
 		sshOpts, err = sshutil.CommonOpts(ctx, sshExe, false)
 		if err != nil {
-			return err
+			return nil, err
 		}
 	}
 	sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
 
-	sshCmd := exec.CommandContext(ctx, arg0, append(sshArgs, scpArgs...)...)
-	sshCmd.Stdin = cmd.InOrStdin()
-	sshCmd.Stdout = cmd.OutOrStdout()
-	sshCmd.Stderr = cmd.ErrOrStderr()
-	logrus.Debugf("executing scp (may take a long time): %+v", sshCmd.Args)
+	return exec.CommandContext(ctx, command, append(sshArgs, scpArgs...)...), nil
+}
 
-	// TODO: use syscall.Exec directly (results in losing tty?)
-	return sshCmd.Run()
+func rsyncCommand(ctx context.Context, command string, copyPaths []*copyPath, verbose, recursive bool) (*exec.Cmd, error) {
+	rsyncFlags := []string{"-a"}
+
+	if verbose {
+		rsyncFlags = append(rsyncFlags, "-v", "--progress")
+	} else {
+		rsyncFlags = append(rsyncFlags, "-q")
+	}
+
+	if recursive {
+		rsyncFlags = append(rsyncFlags, "-r")
+	}
+
+	rsyncArgs := make([]string, 0, len(rsyncFlags)+len(copyPaths))
+	rsyncArgs = append(rsyncArgs, rsyncFlags...)
+
+	var sshCmd string
+	var remoteInstance *limatype.Instance
+
+	for _, cp := range copyPaths {
+		if cp.isRemote {
+			if remoteInstance == nil {
+				remoteInstance = cp.instance
+				sshExe, err := sshutil.NewSSHExe()
+				if err != nil {
+					return nil, err
+				}
+				sshOpts, err := sshutil.SSHOpts(ctx, sshExe, cp.instance.Dir, *cp.instance.Config.User.Name, false, false, false, false)
+				if err != nil {
+					return nil, err
+				}
+
+				sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
+				sshCmd = fmt.Sprintf("ssh -p %d %s", cp.instance.SSHLocalPort, strings.Join(sshArgs, " "))
+			}
+		}
+	}
+
+	if sshCmd != "" {
+		rsyncArgs = append(rsyncArgs, "-e", sshCmd)
+	}
+
+	for _, cp := range copyPaths {
+		if cp.isRemote {
+			rsyncArgs = append(rsyncArgs, fmt.Sprintf("%s@127.0.0.1:%s", *cp.instance.Config.User.Name, cp.path))
+		} else {
+			rsyncArgs = append(rsyncArgs, cp.path)
+		}
+	}
+
+	return exec.CommandContext(ctx, command, rsyncArgs...), nil
 }
diff --git a/cmd/limactl/usernet.go b/cmd/limactl/usernet.go
index 37777de6..9c11ce39 100644
--- a/cmd/limactl/usernet.go
+++ b/cmd/limactl/usernet.go
@@ -7,7 +7,9 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"os/signal"
 	"strconv"
+	"syscall"
 
 	"github.com/spf13/cobra"
 
@@ -77,10 +79,13 @@ func usernetAction(cmd *cobra.Command, _ []string) error {
 	os.RemoveAll(qemuSocket)
 	os.RemoveAll(fdSocket)
 
+	ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
+	defer cancel()
+
 	// Environment Variables
 	// LIMA_USERNET_RESOLVE_IP_ADDRESS_TIMEOUT: Specifies the timeout duration for resolving IP addresses in minutes. Default is 2 minutes.
 
-	return usernet.StartGVisorNetstack(cmd.Context(), &usernet.GVisorNetstackOpts{
+	return usernet.StartGVisorNetstack(ctx, &usernet.GVisorNetstackOpts{
 		MTU:           mtu,
 		Endpoint:      endpoint,
 		QemuSocket:    qemuSocket,
diff --git a/hack/bats/helpers/limactl.bash b/hack/bats/helpers/limactl.bash
new file mode 100644
index 00000000..e0c312e0
--- /dev/null
+++ b/hack/bats/helpers/limactl.bash
@@ -0,0 +1,16 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+# Create a dummy Lima instance for testing purposes. It cannot be started because it doesn't have an actual image.
+# This function intentionally doesn't use create/editflags, but modifies the template with yq instead.
+create_dummy_instance() {
+    local name=$1
+    local expr=$2
+
+    # Template does not validate without an image, and the image must point to a file that exists (for clonefile).
+    local template="{images: [location: /etc/profile]}"
+    if [[ -n $expr ]]; then
+        template="$(limactl yq "$expr" <<<"$template")"
+    fi
+    limactl create --name "$name" - <<<"$template"
+}
diff --git a/hack/bats/helpers/load.bash b/hack/bats/helpers/load.bash
index 6ae7b49c..84f2a22c 100644
--- a/hack/bats/helpers/load.bash
+++ b/hack/bats/helpers/load.bash
@@ -1,3 +1,6 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
 set -o errexit -o nounset -o pipefail
 
 # Don't run the tests in ~/.lima because they may destroy _config, _templates etc.
@@ -17,8 +20,15 @@ source "$PATH_BATS_ROOT/lib/bats-support/load.bash"
 source "$PATH_BATS_ROOT/lib/bats-assert/load.bash"
 source "$PATH_BATS_ROOT/lib/bats-file/load.bash"
 
+source "$PATH_BATS_HELPERS/limactl.bash"
+source "$PATH_BATS_HELPERS/logs.bash"
+
 bats_require_minimum_version 1.5.0
 
+run_e() {
+    run --separate-stderr "$@"
+}
+
 # If called from foo() this function will call local_foo() if it exist.
 call_local_function() {
     local func
@@ -45,4 +55,10 @@ setup() {
 }
 teardown() {
     call_local_function
-}
\ No newline at end of file
+}
+
+assert_output_lines_count() {
+    assert_equal "${#lines[@]}" "$1"
+}
+
+
diff --git a/hack/bats/helpers/logs.bash b/hack/bats/helpers/logs.bash
new file mode 100644
index 00000000..fd39f86a
--- /dev/null
+++ b/hack/bats/helpers/logs.bash
@@ -0,0 +1,30 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+# Format the string the way strconv.Quote() would do.
+# If the input ends with an ellipsis then no closing quote will be added (and the … will be removed).
+quote_msg() {
+    local quoted
+    quoted=$(sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/^/"/' <<<"$1")
+    if [[ $quoted == *… ]]; then
+        echo "${quoted%…}"
+    else
+        echo "${quoted}\""
+  fi
+}
+
+assert_fatal() {
+    assert_stderr_line --partial "level=fatal msg=$(quote_msg "$1")"
+}
+assert_error() {
+    assert_stderr_line --partial "level=error msg=$(quote_msg "$1")"
+}
+assert_warning() {
+    assert_stderr_line --partial "level=warning msg=$(quote_msg "$1")"
+}
+assert_info() {
+    assert_stderr_line --partial "level=info msg=$(quote_msg "$1")"
+}
+assert_debug() {
+    assert_stderr_line --partial "level=debug msg=$(quote_msg "$1")"
+}
diff --git a/hack/test-templates.sh b/hack/test-templates.sh
index 7faa5f2a..3322a780 100755
--- a/hack/test-templates.sh
+++ b/hack/test-templates.sh
@@ -260,6 +260,57 @@ if [ "$got" != "$expected" ]; then
 	exit 1
 fi
 
+INFO "Testing limactl copy command with scp backend"
+tmpfile_scp="$tmpdir/lima-hostname-scp"
+rm -f "$tmpfile_scp"
+tmpfile_scp_host=$tmpfile_scp
+if [ "${OS_HOST}" = "Msys" ]; then
+	tmpfile_scp_host="$(cygpath -w "$tmpfile_scp")"
+fi
+limactl cp --backend=scp "$NAME":/etc/hostname "$tmpfile_scp_host"
+expected="$(limactl shell "$NAME" cat /etc/hostname)"
+got="$(cat "$tmpfile_scp")"
+INFO "/etc/hostname (scp): expected=${expected}, got=${got}"
+if [ "$got" != "$expected" ]; then
+	ERROR "copy command with scp backend did not fetch the file"
+	exit 1
+fi
+
+if command -v rsync >/dev/null && limactl shell "$NAME" command -v rsync >/dev/null 2>&1; then
+	INFO "Testing limactl copy command with rsync backend"
+	tmpfile_rsync="$tmpdir/lima-hostname-rsync"
+	rm -f "$tmpfile_rsync"
+	tmpfile_rsync_host=$tmpfile_rsync
+	if [ "${OS_HOST}" = "Msys" ]; then
+		tmpfile_rsync_host="$(cygpath -w "$tmpfile_rsync")"
+	fi
+	limactl cp --backend=rsync "$NAME":/etc/hostname "$tmpfile_rsync_host"
+	expected="$(limactl shell "$NAME" cat /etc/hostname)"
+	got="$(cat "$tmpfile_rsync")"
+	INFO "/etc/hostname (rsync): expected=${expected}, got=${got}"
+	if [ "$got" != "$expected" ]; then
+		ERROR "copy command with rsync backend did not fetch the file"
+		exit 1
+	fi
+
+	INFO "Testing limactl copy command with rsync backend (verbose, recursive)"
+	testdir="$tmpdir/test-rsync-dir"
+	mkdir -p "$testdir"
+	echo "test content" >"$testdir/testfile.txt"
+	limactl cp --backend=rsync -r -v "$testdir" "$NAME":/tmp/
+	if ! limactl shell "$NAME" test -f /tmp/test-rsync-dir/testfile.txt; then
+		ERROR "rsync recursive copy failed"
+		exit 1
+	fi
+	rsync_content="$(limactl shell "$NAME" cat /tmp/test-rsync-dir/testfile.txt)"
+	if [ "$rsync_content" != "test content" ]; then
+		ERROR "rsync file content mismatch"
+		exit 1
+	fi
+else
+	INFO "Skipping rsync backend test (rsync not available on host or guest)"
+fi
+
 INFO "Testing limactl command with escaped characters"
 limactl shell "$NAME" bash -c "$(echo -e '\n\techo foo\n\techo bar')"
 
diff --git a/pkg/cidata/cidata.TEMPLATE.d/boot/30-install-packages.sh b/pkg/cidata/cidata.TEMPLATE.d/boot/30-install-packages.sh
index e32a6992..a274b0a0 100644
--- a/pkg/cidata/cidata.TEMPLATE.d/boot/30-install-packages.sh
+++ b/pkg/cidata/cidata.TEMPLATE.d/boot/30-install-packages.sh
@@ -53,6 +53,9 @@ if head -c 4 "$(command -v apt-get)" | grep -qP '\x7fELF' >/dev/null 2>&1; then
 	if [ "${LIMA_CIDATA_CONTAINERD_USER}" = 1 ] && ! command -v newuidmap >/dev/null 2>&1; then
 		pkgs="${pkgs} uidmap fuse3 dbus-user-session"
 	fi
+	if ! command -v rsync >/dev/null 2>&1; then
+		pkgs="${pkgs} rsync"
+	fi
 	if [ -n "${pkgs}" ]; then
 		DEBIAN_FRONTEND=noninteractive
 		export DEBIAN_FRONTEND
@@ -83,6 +86,9 @@ elif command -v dnf >/dev/null 2>&1; then
 			pkgs="${pkgs} fuse3"
 		fi
 	fi
+	if ! command -v rsync >/dev/null 2>&1; then
+		pkgs="${pkgs} rsync"
+	fi
 	if [ -n "${pkgs}" ] || [ -n "${extrapkgs}" ]; then
 		dnf_install_flags="-y --setopt=install_weak_deps=False"
 		epel_install_flags=""
@@ -148,6 +154,9 @@ elif command -v yum >/dev/null 2>&1; then
 			pkgs="${pkgs} fuse3"
 		fi
 	fi
+	if ! command -v rsync >/dev/null 2>&1; then
+		pkgs="${pkgs} rsync"
+	fi
 	if [ -n "${pkgs}" ]; then
 		# shellcheck disable=SC2086
 		yum install ${yum_install_flags} ${pkgs}
@@ -160,6 +169,9 @@ elif command -v pacman >/dev/null 2>&1; then
 			pkgs="${pkgs} sshfs"
 		fi
 	fi
+	if ! command -v rsync >/dev/null 2>&1; then
+		pkgs="${pkgs} rsync"
+	fi
 	# other dependencies are preinstalled on Arch Linux
 	if [ -n "${pkgs}" ]; then
 		# shellcheck disable=SC2086
@@ -178,6 +190,9 @@ elif command -v zypper >/dev/null 2>&1; then
 	if [ "${LIMA_CIDATA_CONTAINERD_USER}" = 1 ] && ! command -v mount.fuse3 >/dev/null 2>&1; then
 		pkgs="${pkgs} fuse3"
 	fi
+	if ! command -v rsync >/dev/null 2>&1; then
+		pkgs="${pkgs} rsync"
+	fi
 	if [ -n "${pkgs}" ]; then
 		# shellcheck disable=SC2086
 		zypper --non-interactive install -y --no-recommends ${pkgs}
@@ -192,6 +207,9 @@ elif command -v apk >/dev/null 2>&1; then
 	if [ "${INSTALL_IPTABLES}" = 1 ] && ! command -v iptables >/dev/null 2>&1; then
 		pkgs="${pkgs} iptables"
 	fi
+	if ! command -v rsync >/dev/null 2>&1; then
+		pkgs="${pkgs} rsync"
+	fi
 	if [ -n "${pkgs}" ]; then
 		apk update
 		# shellcheck disable=SC2086
diff --git a/pkg/driver/qemu/qemu.go b/pkg/driver/qemu/qemu.go
index 858dad02..1af03ae0 100644
--- a/pkg/driver/qemu/qemu.go
+++ b/pkg/driver/qemu/qemu.go
@@ -935,7 +935,7 @@ func Cmdline(ctx context.Context, cfg Config) (exe string, args []string, err er
 				options += fmt.Sprintf(",path=%s", f.Location)
 				options += fmt.Sprintf(",security_model=%s", *f.NineP.SecurityModel)
 				if !*f.Writable {
-					options += ",readonly"
+					options += ",readonly=on"
 				}
 				args = append(args, "-virtfs", options)
 			case limatype.VIRTIOFS:
diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go
index c1c73d90..3affdf6b 100644
--- a/pkg/hostagent/hostagent.go
+++ b/pkg/hostagent/hostagent.go
@@ -948,12 +948,16 @@ func (a *HostAgent) watchCloudInitProgress(ctx context.Context) {
 		"-p", strconv.Itoa(a.sshLocalPort),
 		"127.0.0.1",
 		"sh", "-c",
-		`"sudo tail -n +$(sudo awk '
-			BEGIN{b=1; e=1}
-			/^Cloud-init.* finished/{e=NR}
-			/.*/{if(NR>e){b=e+1}}
-			END{print b}
-		' /var/log/cloud-init-output.log) -f /var/log/cloud-init-output.log"`,
+		`"if command -v systemctl >/dev/null 2>&1 && systemctl is-enabled -q cloud-init-main.service; then
+			sudo journalctl -u cloud-init-main.service -b -S @0 -o cat -f
+		else
+			sudo tail -n +$(sudo awk '
+				BEGIN{b=1; e=1}
+				/^Cloud-init.* finished/{e=NR}
+				/.*/{if(NR>e){b=e+1}}
+				END{print b}
+			' /var/log/cloud-init-output.log) -f /var/log/cloud-init-output.log
+		fi"`,
 	)
 
 	cmd = exec.CommandContext(ctx, a.sshConfig.Binary(), args...)
@@ -971,6 +975,7 @@ func (a *HostAgent) watchCloudInitProgress(ctx context.Context) {
 	}
 
 	scanner := bufio.NewScanner(stdout)
+	cloudInitMainServiceStarted := false
 	cloudInitFinished := false
 
 	for scanner.Scan() {
@@ -979,11 +984,19 @@ func (a *HostAgent) watchCloudInitProgress(ctx context.Context) {
 			continue
 		}
 
-		if !cloudInitFinished {
-			if isCloudInitFinished(line) {
-				logrus.Debug("Cloud-init completion detected via log pattern")
-				cloudInitFinished = true
+		if !cloudInitMainServiceStarted {
+			if isStartedCloudInitMainService(line) {
+				logrus.Debug("cloud-init-main.service started detected via log pattern")
+				cloudInitMainServiceStarted = true
+			} else if !cloudInitFinished {
+				if isCloudInitFinished(line) {
+					logrus.Debug("Cloud-init completion detected via log pattern")
+					cloudInitFinished = true
+				}
 			}
+		} else if !cloudInitFinished && isDeactivatedCloudInitMainService(line) {
+			logrus.Debug("cloud-init-main.service deactivated detected via log pattern")
+			cloudInitFinished = true
 		}
 
 		a.emitCloudInitProgressEvent(ctx, &events.CloudInitProgress{
@@ -1048,6 +1061,17 @@ func isCloudInitFinished(line string) bool {
 	return strings.Contains(line, "cloud-init") && strings.Contains(line, "finished")
 }
 
+func isStartedCloudInitMainService(line string) bool {
+	line = strings.ToLower(strings.TrimSpace(line))
+	return strings.HasPrefix(line, "started cloud-init-main.service")
+}
+
+func isDeactivatedCloudInitMainService(line string) bool {
+	line = strings.ToLower(strings.TrimSpace(line))
+	// Deactivated event lines end with a line reporting consumed CPU time, etc.
+	return strings.HasPrefix(line, "cloud-init-main.service: consumed")
+}
+
 func copyToHost(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote string) error {
 	args := sshConfig.Args()
 	args = append(args,
diff --git a/pkg/networks/usernet/gvproxy.go b/pkg/networks/usernet/gvproxy.go
index 4df2c62d..363ac996 100644
--- a/pkg/networks/usernet/gvproxy.go
+++ b/pkg/networks/usernet/gvproxy.go
@@ -6,6 +6,7 @@ package usernet
 import (
 	"bufio"
 	"context"
+	"errors"
 	"fmt"
 	"net"
 	"net/http"
@@ -128,9 +129,16 @@ func listenQEMU(ctx context.Context, vn *virtualnetwork.VirtualNetwork) error {
 
 	go func() {
 		defer listener.Close()
+		<-ctx.Done()
+	}()
+
+	go func() {
 		for {
 			conn, err := listener.Accept()
 			if err != nil {
+				if errors.Is(err, net.ErrClosed) {
+					return
+				}
 				logrus.Error("QEMU accept failed", err)
 			}
 
@@ -162,10 +170,18 @@ func listenFD(ctx context.Context, vn *virtualnetwork.VirtualNetwork) error {
 
 	go func() {
 		defer listener.Close()
+		<-ctx.Done()
+	}()
+
+	go func() {
 		for {
 			conn, err := listener.Accept()
 			if err != nil {
+				if errors.Is(err, net.ErrClosed) {
+					return
+				}
 				logrus.Error("FD accept failed", err)
+				continue // since conn is nil
 			}
 
 			files, err := fd.Get(conn.(*net.UnixConn), 1, []string{"client"})
@@ -202,20 +218,20 @@ func listenFD(ctx context.Context, vn *virtualnetwork.VirtualNetwork) error {
 }
 
 func httpServe(ctx context.Context, g *errgroup.Group, ln net.Listener, mux http.Handler) {
+	s := &http.Server{
+		Handler:      mux,
+		ReadTimeout:  10 * time.Second,
+		WriteTimeout: 10 * time.Second,
+	}
 	g.Go(func() error {
 		<-ctx.Done()
-		return ln.Close()
+		return s.Close()
 	})
 	g.Go(func() error {
-		s := &http.Server{
-			Handler:      mux,
-			ReadTimeout:  10 * time.Second,
-			WriteTimeout: 10 * time.Second,
-		}
 		err := s.Serve(ln)
 		if err != nil {
-			if err != http.ErrServerClosed {
-				return err
+			if err == http.ErrServerClosed {
+				return nil
 			}
 			return err
 		}
diff --git a/pkg/networks/usernet/udpfileconn.go b/pkg/networks/usernet/udpfileconn.go
index ef04b841..ea8696c0 100644
--- a/pkg/networks/usernet/udpfileconn.go
+++ b/pkg/networks/usernet/udpfileconn.go
@@ -16,8 +16,7 @@ type UDPFileConn struct {
 func (conn *UDPFileConn) Read(b []byte) (n int, err error) {
 	// Check if the connection has been closed
 	if err := conn.SetReadDeadline(time.Time{}); err != nil {
-		var opErr *net.OpError
-		if errors.As(err, &opErr) && opErr.Err.Error() == "use of closed network connection" {
+		if errors.Is(err, net.ErrClosed) {
 			return 0, errors.New("UDPFileConn connection closed")
 		}
 	}
diff --git a/templates/README.md b/templates/README.md
index 80f5f0f3..6ea2b4a2 100644
--- a/templates/README.md
+++ b/templates/README.md
@@ -20,8 +20,8 @@ Distro:
 - [`archlinux`](./archlinux.yaml): ☆Arch Linux
 - [`centos-stream-9`](./centos-stream-9.yaml), `centos-stream.yaml`: CentOS Stream 9
 - [`centos-stream-10`](./centos-stream-10.yaml): CentOS Stream 10
-- [`debian-11`](./debian-11.yaml): Debian GNU/Linux 11(bullseye)
-- [`debian-12`](./debian-12.yaml), `debian.yaml`: ⭐Debian GNU/Linux 12(bookworm)
+- [`debian-12`](./debian-12.yaml): Debian GNU/Linux 12(bookworm)
+- [`debian-13`](./debian-13.yaml), `debian.yaml`: ⭐Debian GNU/Linux 13(trixie)
 - [`fedora-41`](./fedora-41.yaml): Fedora 41
 - [`fedora-42`](./fedora-42.yaml), `fedora.yaml`: ⭐Fedora 42
 - [`opensuse-leap`](./opensuse-leap.yaml), `opensuse.yaml`: ⭐openSUSE Leap
diff --git a/templates/_images/debian-11.yaml b/templates/_images/debian-11.yaml
index 640bf283..85d8ae59 100644
--- a/templates/_images/debian-11.yaml
+++ b/templates/_images/debian-11.yaml
@@ -1,3 +1,4 @@
+# DEPRECATED: Debian 11 (Bullseye) is deprecated. Use debian-12 (Bookworm) or debian-13 (Trixie) instead.
 images:
 # Try to use release-yyyyMMdd image if available. Note that release-yyyyMMdd will be removed after several months.
 
diff --git a/templates/_images/debian-13.yaml b/templates/_images/debian-13.yaml
new file mode 100644
index 00000000..d9deea4b
--- /dev/null
+++ b/templates/_images/debian-13.yaml
@@ -0,0 +1,22 @@
+images:
+# Try to use release-yyyyMMdd image if available. Note that release-yyyyMMdd will be removed after several months.
+
+- location: "https://cloud.debian.org/images/cloud/trixie/20250911-2232/debian-13-genericcloud-amd64-20250911-2232.qcow2"
+  arch: "x86_64"
+  digest: "sha512:82ca129c665d6d1d9a919aa64a1c275646755de078a4e07de83d741e28f38f506b907f46432c0368d4ab3c740cfacf9894238be7052aeb46b1933259cabecc62"
+- location: "https://cloud.debian.org/images/cloud/trixie/20250911-2232/debian-13-genericcloud-arm64-20250911-2232.qcow2"
+  arch: "aarch64"
+  digest: "sha512:4c9cf2f8733670ff8d56a88b6e0f04900c55f68c1a4757a257605b314062fdd6dbe37b61b8b9ecda376a16770e9f12aeddc23bd5c5f499f91443d99308790a32"
+# Fallback to the latest release image.
+# Hint: run `limactl prune` to invalidate the cache
+
+- location: https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-13-genericcloud-amd64-daily.qcow2
+  arch: x86_64
+
+- location: https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-13-genericcloud-arm64-daily.qcow2
+  arch: aarch64
+
+- location: https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-13-genericcloud-ppc64el-daily.qcow2
+  arch: ppc64le
+
+mountTypesUnsupported: [9p]
diff --git a/templates/debian-11.yaml b/templates/debian-13.yaml
similarity index 66%
rename from templates/debian-11.yaml
rename to templates/debian-13.yaml
index 724cf448..a23abab0 100644
--- a/templates/debian-11.yaml
+++ b/templates/debian-13.yaml
@@ -1,5 +1,5 @@
 minimumLimaVersion: 1.1.0
 
 base:
-- template://_images/debian-11
+- template://_images/debian-13
 - template://_default/mounts
diff --git a/templates/debian.yaml b/templates/debian.yaml
index 9fc2ffbe..99e7fcf3 120000
--- a/templates/debian.yaml
+++ b/templates/debian.yaml
@@ -1 +1 @@
-debian-12.yaml
\ No newline at end of file
+debian-13.yaml
\ No newline at end of file
diff --git a/templates/experimental/debian-13.yaml b/templates/experimental/debian-13.yaml
deleted file mode 100644
index 242058e8..00000000
--- a/templates/experimental/debian-13.yaml
+++ /dev/null
@@ -1,15 +0,0 @@
-minimumLimaVersion: 1.1.0
-
-base: template://_default/mounts
-
-images:
-- location: https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-13-genericcloud-amd64-daily.qcow2
-  arch: x86_64
-
-- location: https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-13-genericcloud-arm64-daily.qcow2
-  arch: aarch64
-
-- location: https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-13-genericcloud-ppc64el-daily.qcow2
-  arch: ppc64le
-
-mountTypesUnsupported: [9p]
diff --git a/templates/experimental/debian-testing.yaml b/templates/experimental/debian-testing.yaml
deleted file mode 120000
index 99e7fcf3..00000000
--- a/templates/experimental/debian-testing.yaml
+++ /dev/null
@@ -1 +0,0 @@
-debian-13.yaml
\ No newline at end of file


Test patch:
diff --git a/hack/bats/tests/00-yq.bats b/hack/bats/tests/00-yq.bats
new file mode 100644
index 00000000..ee00c465
--- /dev/null
+++ b/hack/bats/tests/00-yq.bats
@@ -0,0 +1,37 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+load "../helpers/load"
+
+@test 'make sure the yq subcommand exists' {
+    run -0 limactl yq --version
+    assert_output --regexp '^yq .*mikefarah.* version v'
+}
+
+@test 'yq can evaluate yq expressions' {
+    run -0 limactl yq .foo=42 <<<""
+    assert_output 'foo: 42'
+}
+
+@test 'yq command understand yq options' {
+    run -0 limactl yq -n -o json -I 0 .foo=42
+    assert_output '{"foo":42}'
+}
+
+@test 'yq errors set non-zero exit code' {
+    run -1 limactl yq -n foo
+    assert_output --partial "invalid input"
+}
+
+@test 'yq works as a multi-call binary' {
+    # multi-call command detection strips all extensions
+    YQ="yq.lima.exe"
+    ln -sf "$(which limactl)" "${BATS_TEST_TMPDIR}/${YQ}"
+    export PATH="$BATS_TEST_TMPDIR:$PATH"
+
+    run -0 "$YQ" --version
+    assert_output --regexp '^yq .*mikefarah.* version v'
+
+    run -0 "$YQ" -n -o json -I 0 .foo=42
+    assert_output '{"foo":42}'
+}
diff --git a/hack/bats/tests/01-list.bats b/hack/bats/tests/01-list.bats
new file mode 100644
index 00000000..604d21bc
--- /dev/null
+++ b/hack/bats/tests/01-list.bats
@@ -0,0 +1,282 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+load "../helpers/load"
+
+# Use a separate LIMA_HOME for this test that we can just wipe because it will never have running instances.
+# We cannot use $BATS_FILE_TMPDIR because `limactl create` will complain about max socket path name length.
+LOCAL_LIMA_HOME="${LIMA_HOME:?}/_bats"
+
+local_setup_file() {
+    export LIMA_HOME="${LOCAL_LIMA_HOME:?}"
+    rm -rf "${LIMA_HOME:?}"
+
+    run -0 create_dummy_instance "foo" '.disk = "1M"'
+    run -0 create_dummy_instance "bar" '.disk = "2M"'
+    run -0 create_dummy_instance "baz" '.disk = "3M"'
+}
+
+local_setup() {
+    export LIMA_HOME="${LOCAL_LIMA_HOME:?}"
+}
+
+# In Go templates "{{json .}}" will encode empty maps (e.g. "foo": {}) even if the
+# map has the "json:",omitempty" tag. "{{yaml .}}" does omit empty maps, so remove
+# them from the JSON output to make it comparable to the YAML one.
+#
+# TODO should we modify `limactl list` to remove empty maps by default
+# since we pass the output through yq anyways?
+canonical_json() {
+    local empty_maps='.. | select(tag == "!!map" and length == 0)'
+    while limactl yq --exit-status --input-format json "[${empty_maps}] | length > 0" <<<"$output" >/dev/null 2>&1; do
+        run -0 limactl yq --input-format json --output-format json --indent 0 "del(${empty_maps})" <<<"$output"
+    done
+
+}
+
+@test 'list with no running instances shows a warning and exits without error' {
+    export LIMA_HOME="$BATS_TEST_TMPDIR"
+    run_e -0 limactl list
+    assert_warning 'No instance found. Run `limactl create` to create an instance.'
+}
+
+@test 'check plain list output' {
+    # also verifies that `ls` is an alias for `list`
+    run -0 limactl ls
+
+    # instances will be sorted alphabetically
+    assert_line --index 0 --regexp '^NAME +STATUS.+DISK'
+    assert_line --index 1 --regexp '^bar +Stopped.+ 2MiB'
+    assert_line --index 2 --regexp '^baz +Stopped.+ 3MiB'
+    assert_line --index 3 --regexp '^foo +Stopped.+ 1MiB'
+    # there is no other output
+    assert_output_lines_count 4
+}
+
+@test 'only list selected instances' {
+    run -0 limactl ls foo bar
+
+    # instances will be sorted in the order they were specified
+    assert_line --index 0 --regexp '^NAME'
+    assert_line --index 1 --regexp '^foo'
+    assert_line --index 2 --regexp '^bar'
+    refute_line --partial baz
+    assert_output_lines_count 3
+}
+
+@test 'requesting non-existing instance is an error' {
+    run_e -1 limactl ls foo foobar bar
+    assert_warning 'No instance matching foobar found.'
+    assert_fatal 'unmatched instances'
+
+    # existing instances are still listed
+    assert_line --index 0 --regexp '^NAME'
+    assert_line --index 1 --regexp '^foo'
+    assert_line --index 2 --regexp '^bar'
+    assert_output_lines_count 3
+}
+
+@test '--quiet option shows only names, no header' {
+    run -0 limactl list --quiet foo bar
+    assert_line --index 0 foo
+    assert_line --index 1 bar
+    assert_output_lines_count 2
+}
+
+@test '--format json returns JSON output' {
+    run -0 limactl ls --format json foo bar
+
+    # test may be too strict in expecting "name" to be the first key on the line
+    assert_line --index 0 --regexp '^\{"name":"foo",'
+    assert_line --index 1 --regexp '^\{"name":"bar",'
+    assert_output_lines_count 2
+}
+
+@test '--json is shorthand for --format json' {
+    run -0 limactl ls --format json foo bar
+    format_json=$output
+
+    run -0 limactl ls --json foo bar
+    assert_output "$format_json"
+}
+
+@test '--format YAML returns YAML documents' {
+    # save canonical JSON output with empty maps removed, for comparison
+    run -0 limactl ls foo bar --format json
+    canonical_json
+    json=$output
+
+    run -0 limactl ls --format yaml foo bar
+    yaml=$output
+
+    assert_line --regexp '^name: foo'
+    assert_line --regexp '^name: bar'
+    refute_line --regexp '^name: baz'
+
+    # verify that the output consists of 2 documents
+    run -0 limactl yq 'true' <<<"$yaml"
+    assert_output_lines_count 2
+
+    # convert YAML to JSON
+    run -0 limactl yq --input-format yaml --output-format json  --indent 0 "." <<<"$yaml"
+    assert_output_lines_count 2
+
+    # verify it matches the canonical JSON output
+    assert_output "$json"
+
+}
+
+@test 'JSON output to terminal is colorized, but semantically identical' {
+    run -0 limactl ls foo bar --format json
+    json=$output
+
+    # colorize output even when stdout is not a tty
+    export _LIMA_OUTPUT_IS_TTY=1
+    run -0 limactl ls foo bar --format json
+    colorized=$output
+
+    # check if the output contains an ANSI "reset mode" sequence ("ESC[0m")
+    run -0 cat -v <<<"$output"
+    assert_output --partial "^[[0m"
+
+    # remove all ANSI formatting codes from the output
+    run -0 sed 's/\x1b\[[0-9;]*m//g' <<<"$colorized"
+
+    # Flatten the pretty-printed JSON to a single line per object with no extra whitespace.
+    # yq processes JSON objects in sequence (when input format is set to json) and
+    # does not require each object to be on a single line.
+    run -0 limactl yq --input-format json --output-format json  --indent 0 "." <<<"$output"
+    assert_output_lines_count 2
+
+    # compare to the regular --format json output
+    assert_output "$json"
+}
+
+@test 'YAML output to terminal is colorized, but semantically identical' {
+    # save canonical output without colors and empty maps removed
+    run -0 limactl ls foo bar --format json
+    canonical_json
+    json=$output
+
+    # colorize output even when stdout is not a tty
+    export _LIMA_OUTPUT_IS_TTY=1
+    run -0 limactl ls foo bar --format yaml
+    colorized=$output
+
+    # check if the output contains an ANSI "reset mode" sequence ("ESC[0m")
+    run -0 cat -v <<<"$output"
+    assert_output --partial "^[[0m"
+
+    # remove all ANSI formatting codes from the output
+    run -0 sed 's/\x1b\[[0-9;]*m//g' <<<"$colorized"
+    yaml=$output
+
+    # Verify that the output consists of 2 documents
+    run -0 limactl yq 'true' <<<"$yaml"
+    assert_output_lines_count 2
+
+    # convert the pretty-printed YAML to JSON Lines format with no whitespace
+    run -0 limactl yq --indent 0 --input-format yaml --output-format json "." <<<"$yaml"
+    assert_output_lines_count 2
+
+    # verify it matches the canonical JSON output
+    assert_output "$json"
+}
+
+@test '--all-fields includes all fields in the table' {
+    skip "only works with output to a terminal (#3986)"
+    # TODO provide a way to specify the width, e.g. with `--width 120`
+    # See https://github.com/lima-vm/lima/issues/3986
+}
+
+@test 'Use field names in Go template format' {
+    run -0 limactl ls foo bar --format '{{.Name}} {{.Disk}}'
+    assert_line --index 0 "foo 1048576"
+    assert_line --index 1 "bar 2097152"
+}
+
+@test '--list-fields list all available fields' {
+    run -0 limactl ls --list-fields
+    assert_line Name
+    assert_line CPUs
+    assert_line Memory
+    # All field names start with an uppercase letter and don't contain any spaces
+    refute_line --regexp '^[^A-Z]'
+    refute_line --partial ' '
+ }
+
+ @test '--list-fields does not list deprecated field, but they are still available' {
+    run -0 limactl ls --list-fields
+
+    # no deprecated fields are listed
+    refute_line "HostArch"
+    refute_line "HostOS"
+    refute_line "IdentityFile"
+    refute_line "LimaHome"
+
+    # all deprecated fields exist and produce output
+    run -0 limactl ls foo --format '{{.HostArch}}'
+    assert_output
+    run -0 limactl ls foo --format '{{.HostOS}}'
+    assert_output
+    run -0 limactl ls foo --format '{{.IdentityFile}}'
+    assert_output
+    run -0 limactl ls foo --format '{{.LimaHome}}'
+    assert_output "$LIMA_HOME"
+
+    # verify that a non-existing field throws an error and produces no output
+    # TODO the error message is not really end-user friendly, not sure if we can do something about it
+    run_e -1 limactl ls foo --format '{{.Unknown}}'
+    assert_stderr --regexp "level=fatal.*can't evaluate field Unknown"
+    refute_output
+}
+
+@test '--quiet option can only be used with format --table' {
+    # TODO the error message is incorrect, it can be used with --yq
+    run_e -1 limactl list --quiet --format json
+    assert_fatal "option --quiet can only be used with '--format table'"
+
+    run_e -1 limactl list --quiet --format yaml
+    assert_fatal "option --quiet can only be used with '--format table'"
+
+    run_e -1 limactl list --quiet --format '{{.Name}} {{.Disk}}'
+    assert_fatal "option --quiet can only be used with '--format table'"
+}
+
+@test '--yq option implies --format json' {
+    run -0 limactl ls baz --yq '.config'
+    assert_output --regexp '^\{"'
+
+    run -0 limactl yq '.disk' <<<"$output"
+    assert_output "3M"
+}
+
+@test '--yq option can be used with --format yaml' {
+    run -0 limactl ls baz --yq '.config' --format yaml
+    assert_line "disk: 3M"
+}
+
+@test '--yq option can be specified multiple times' {
+    run -0 limactl ls foo --yq '.config' --yq '.user' --yq '.uid'
+    assert_output "$UID"
+
+    run -0 limactl ls --yq 'select(.disk > 1024*1024)' --yq 'select(.name | test("z"))' --yq '.name'
+    assert_output "baz"
+}
+
+@test '--yq option is incompatible with --format table or Go templates' {
+    run_e -1 limactl ls --yq '.name' --format table
+    assert_fatal "option --yq only works with --format json or yaml"
+
+    run_e -1 limactl ls --yq '.name' --format '{{.Name}} {{.Disk}}'
+    assert_fatal "option --yq only works with --format json or yaml"
+}
+
+@test '--quiet option can be used with --yq' {
+    run -0 limactl ls --quiet
+    assert_line --index 0 "bar"
+    assert_output_lines_count 3
+
+    run -0 limactl ls --quiet --yq 'select(.name == "foo")'
+    assert_output "foo"
+}
diff --git a/hack/bats/tests/preserve-env.bats b/hack/bats/tests/preserve-env.bats
index 5aa8c38b..e5325735 100644
--- a/hack/bats/tests/preserve-env.bats
+++ b/hack/bats/tests/preserve-env.bats
@@ -1,3 +1,6 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
 load "../helpers/load"
 
 NAME=bats
diff --git a/hack/bats/tests/protect.bats b/hack/bats/tests/protect.bats
index dcf464cc..170b6e12 100644
--- a/hack/bats/tests/protect.bats
+++ b/hack/bats/tests/protect.bats
@@ -1,3 +1,6 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
 load "../helpers/load"
 
 NAME=dummy
@@ -12,58 +15,57 @@ local_setup_file() {
 }
 
 @test 'create dummy instance' {
-    # needs an image that clonefile() can process; /dev/null doesn't work
-    limactl create --name "$NAME" - <<<"{images: [location: /etc/profile], disk: 1M}"
+    run -0 create_dummy_instance "$NAME" '.disk = "1M"'
 }
 
 @test 'protecting a non-existing instance fails' {
-    run -1 limactl protect "${NOTEXIST}"
-    assert_output --partial 'failed to inspect instance'
+    run_e -1 limactl protect "${NOTEXIST}"
+    assert_fatal "failed to inspect instance \"${NOTEXIST}\"…"
 }
 
 @test 'protecting the dummy instance succeeds' {
-    run -0 limactl protect "$NAME"
-    assert_output --regexp "Protected \\\\\"$NAME\\\\\""
-    assert_file_exists "$LIMA_HOME/$NAME/protected"
+    run_e -0 limactl protect "$NAME"
+    assert_info "Protected \"${NAME}\""
+    assert_file_exists "${LIMA_HOME}/${NAME}/protected"
 }
 
 @test 'protecting it again shows a warning, but succeeds' {
-    run -0 limactl protect "$NAME"
-    assert_output --partial 'already protected. Skipping'
-    assert_file_exists "$LIMA_HOME/$NAME/protected"
+    run_e -0 limactl protect "$NAME"
+    assert_warning "Instance \"${NAME}\" is already protected. Skipping."
+    assert_file_exists "${LIMA_HOME}/${NAME}/protected"
 }
 
 @test 'cloning a protected instance creates an unprotected clone' {
-    run -0 limactl clone --yes "$NAME" "$CLONE"
+    run_e -0 limactl clone --yes "$NAME" "$CLONE"
     # TODO there is currently no output from the clone command, which feels wrong
     refute_output
-    assert_file_not_exists "$LIMA_HOME/$CLONE/protected"
+    assert_file_not_exists "${LIMA_HOME}/${CLONE}/protected"
 }
 
 @test 'deleting the unprotected clone instance succeeds' {
-    run -0 limactl delete --force "$CLONE"
-    assert_output --regexp "Deleted \\\\\"$CLONE\\\\\""
+    run_e -0 limactl delete --force "$CLONE"
+    assert_info "Deleted \"${CLONE}\"…"
 }
 
 @test 'deleting protected dummy instance fails' {
-    run -1 limactl delete --force "$NAME"
-    assert_output --partial 'instance is protected'
+    run_e -1 limactl delete --force "$NAME"
+    assert_fatal "failed to delete instance \"${NAME}\": instance is protected…"
     assert_file_exists "$LIMA_HOME/$NAME/protected"
 }
 
 @test 'unprotecting the dummy instance succeeds' {
-    run -0 limactl unprotect "$NAME"
-    assert_output --regexp "Unprotected \\\\\"$NAME\\\\\""
+    run_e -0 limactl unprotect "$NAME"
+    assert_info "Unprotected \"${NAME}\""
     assert_file_not_exists "$LIMA_HOME/$NAME/protected"
 }
 
 @test 'unprotecting it again shows a warning, but succeeds' {
-    run -0 limactl unprotect "$NAME"
-    assert_output --partial "isn't protected. Skipping"
+    run_e -0 limactl unprotect "$NAME"
+    assert_warning "Instance \"${NAME}\" isn't protected. Skipping."
     assert_file_not_exists "$LIMA_HOME/$NAME/protected"
 }
 
 @test 'deleting unprotected dummy instance succeeds' {
-    run -0 limactl delete --force "$NAME"
-    assert_output --regexp "Deleted \\\\\"$NAME\\\\\""
+    run_e -0 limactl delete --force "$NAME"
+    assert_info "Deleted \"${NAME}\"…"
 }