Repository: lima-vm/lima (Go)

Issue:
`limactl shell` working directory switching is not Windows host aware
### Description

There is no path conversion for default `cd` chain https://github.com/lima-vm/lima/blob/ccd3c0cb9fdbef18ee82263a712ac8924a0deca8/cmd/limactl/shell.go#L91 when `limactl shell` is invoked. The code will do nothing useful, but would print useless errors every time starting shell. It is not fatal, but definitely can be improved.

I don't have specific expectation right now - posting it to have a discussion what could be improved or if the path should just be disabled for this specific platform.


`sshutil.go` handles key paths inconsistently on Windows platform
### Description

Code in `sshutil.go` is different for path handling in different commands - `ssh` vs `ssh-keygen`:

It uses filepath for `ssh-keygen` https://github.com/lima-vm/lima/blob/e911564e4a5f3151a3beef5ec2446914e016c745/pkg/sshutil/sshutil.go#L99 but has path translation for `ssh` https://github.com/lima-vm/lima/blob/e911564e4a5f3151a3beef5ec2446914e016c745/pkg/sshutil/sshutil.go#L172

It doesn't look right to mix and match ssh tools from Windows installation and msys2/cygwin counterparts, so, they both should work identically (cygwin/msys2 does support implicit conversion, but if one uses wsl based tooling it will not). The idea is to make this behavior more strict - identify which tooling version is used as Windows adds market to the version output. Alternatively it could be a setting inside VM template as of which kind of tooling is expected - this will reduce overhead of calling `ssh -V` additionally every time.

I plan to work on a PR addressing this.
`limactl cp` doesn't support Windows absolute paths
### Description

There is currently no handling of Windows paths in `limactl cp`. Input is simply split at ":" https://github.com/lima-vm/lima/blob/fbd1f45107b7d388dca52d2266ce3d8ee79742b3/cmd/limactl/copy.go#L80 So, this will try to use drive letter as VM name.

I have tentative plans to tackle it as I progress with my Lima on Windows experiment, but technically writing argument parser and unit tests for it should not be blocked by work in progress state of complete Windows host support.


Discussion:
I'm open to work on a PR, when the acceptable solution is outlined.



Docker handles this by looking for a local file with an absolute path, before looking for a hostname.

`filepath.IsAbs`



Patch:
diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go
index d6333399a22..44263e20838 100644
--- a/cmd/limactl/copy.go
+++ b/cmd/limactl/copy.go
@@ -8,9 +8,12 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"path/filepath"
+	"runtime"
 	"strings"
 
 	"github.com/coreos/go-semver/semver"
+	"github.com/lima-vm/lima/pkg/ioutilx"
 	"github.com/lima-vm/lima/pkg/sshutil"
 	"github.com/lima-vm/lima/pkg/store"
 	"github.com/sirupsen/logrus"
@@ -80,6 +83,16 @@ func copyAction(cmd *cobra.Command, args []string) error {
 	// this assumes that ssh and scp come from the same place, but scp has no -V
 	legacySSH := sshutil.DetectOpenSSHVersion("ssh").LessThan(*semver.New("8.0.0"))
 	for _, arg := range args {
+		if runtime.GOOS == "windows" {
+			if filepath.IsAbs(arg) {
+				arg, err = ioutilx.WindowsSubsystemPath(arg)
+				if err != nil {
+					return err
+				}
+			} else {
+				arg = filepath.ToSlash(arg)
+			}
+		}
 		path := strings.Split(arg, ":")
 		switch len(path) {
 		case 1:
diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go
index 3dee0bac7c2..ff771248e2b 100644
--- a/cmd/limactl/shell.go
+++ b/cmd/limactl/shell.go
@@ -8,11 +8,14 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"path"
+	"runtime"
 	"strconv"
 	"strings"
 
 	"al.essio.dev/pkg/shellescape"
 	"github.com/coreos/go-semver/semver"
+	"github.com/lima-vm/lima/pkg/ioutilx"
 	"github.com/lima-vm/lima/pkg/sshutil"
 	"github.com/lima-vm/lima/pkg/store"
 	"github.com/mattn/go-isatty"
@@ -92,6 +95,9 @@ func shellAction(cmd *cobra.Command, args []string) error {
 		// FIXME: check whether y.Mounts contains the home, not just len > 0
 	} else if len(inst.Config.Mounts) > 0 {
 		hostCurrentDir, err := os.Getwd()
+		if err == nil && runtime.GOOS == "windows" {
+			hostCurrentDir, err = mountDirFromWindowsDir(hostCurrentDir)
+		}
 		if err == nil {
 			changeDirCmd = fmt.Sprintf("cd %s", shellescape.Quote(hostCurrentDir))
 		} else {
@@ -99,6 +105,9 @@ func shellAction(cmd *cobra.Command, args []string) error {
 			logrus.WithError(err).Warn("failed to get the current directory")
 		}
 		hostHomeDir, err := os.UserHomeDir()
+		if err == nil && runtime.GOOS == "windows" {
+			hostHomeDir, err = mountDirFromWindowsDir(hostHomeDir)
+		}
 		if err == nil {
 			changeDirCmd = fmt.Sprintf("%s || cd %s", changeDirCmd, shellescape.Quote(hostHomeDir))
 		} else {
@@ -189,6 +198,14 @@ func shellAction(cmd *cobra.Command, args []string) error {
 	return sshCmd.Run()
 }
 
+func mountDirFromWindowsDir(dir string) (string, error) {
+	dir, err := ioutilx.WindowsSubsystemPath(dir)
+	if err == nil && !strings.HasPrefix(dir, "/mnt/") {
+		dir = path.Join("/mnt", dir)
+	}
+	return dir, err
+}
+
 func shellBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
 	return bashCompleteInstanceNames(cmd)
 }
diff --git a/pkg/ioutilx/ioutilx.go b/pkg/ioutilx/ioutilx.go
index ea1fcf1e08b..cd91e40c77f 100644
--- a/pkg/ioutilx/ioutilx.go
+++ b/pkg/ioutilx/ioutilx.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"io"
 	"os/exec"
+	"path/filepath"
 	"strings"
 
 	"github.com/sirupsen/logrus"
@@ -48,13 +49,11 @@ func FromUTF16leToString(r io.Reader) (string, error) {
 	return string(out), nil
 }
 
-func CanonicalWindowsPath(orig string) string {
-	newPath := orig
-	out, err := exec.Command("cygpath", "-m", orig).CombinedOutput()
+func WindowsSubsystemPath(orig string) (string, error) {
+	out, err := exec.Command("cygpath", filepath.ToSlash(orig)).CombinedOutput()
 	if err != nil {
 		logrus.WithError(err).Errorf("failed to convert path to mingw, maybe not using Git ssh?")
-	} else {
-		newPath = strings.TrimSpace(string(out))
+		return orig, err
 	}
-	return newPath
+	return strings.TrimSpace(string(out)), nil
 }
diff --git a/pkg/osutil/user.go b/pkg/osutil/user.go
index ce72c98c844..b34f5c3d59f 100644
--- a/pkg/osutil/user.go
+++ b/pkg/osutil/user.go
@@ -14,6 +14,7 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/lima-vm/lima/pkg/ioutilx"
 	. "github.com/lima-vm/lima/pkg/must"
 	"github.com/lima-vm/lima/pkg/version/versionutil"
 	"github.com/sirupsen/logrus"
@@ -144,7 +145,7 @@ func LimaUser(limaVersion string, warn bool) *user.User {
 				warnings = append(warnings, warning)
 				limaUser.Gid = formatUidGid(gid)
 			}
-			home, err := call([]string{"cygpath", limaUser.HomeDir})
+			home, err := ioutilx.WindowsSubsystemPath(limaUser.HomeDir)
 			if err != nil {
 				logrus.Debug(err)
 			} else {
@@ -159,6 +160,10 @@ func LimaUser(limaVersion string, warn bool) *user.User {
 				home += ".linux"
 			}
 			if !regexPath.MatchString(limaUser.HomeDir) {
+				// Trim prefix of well known default mounts
+				if strings.HasPrefix(home, "/mnt/") {
+					home = strings.TrimPrefix(home, "/mnt")
+				}
 				warning := fmt.Sprintf("local home %q is not a valid Linux path (must match %q); using %q home instead",
 					limaUser.HomeDir, regexPath.String(), home)
 				warnings = append(warnings, warning)
diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go
index 891279d2c07..1d8a5f15ce6 100644
--- a/pkg/sshutil/sshutil.go
+++ b/pkg/sshutil/sshutil.go
@@ -98,8 +98,15 @@ func DefaultPubKeys(loadDotSSH bool) ([]PubKey, error) {
 		}
 		if err := lockutil.WithDirLock(configDir, func() error {
 			// no passphrase, no user@host comment
+			privPath := filepath.Join(configDir, filenames.UserPrivateKey)
+			if runtime.GOOS == "windows" {
+				privPath, err = ioutilx.WindowsSubsystemPath(privPath)
+				if err != nil {
+					return err
+				}
+			}
 			keygenCmd := exec.Command("ssh-keygen", "-t", "ed25519", "-q", "-N", "",
-				"-C", "lima", "-f", filepath.Join(configDir, filenames.UserPrivateKey))
+				"-C", "lima", "-f", privPath)
 			logrus.Debugf("executing %v", keygenCmd.Args)
 			if out, err := keygenCmd.CombinedOutput(); err != nil {
 				return fmt.Errorf("failed to run %v: %q: %w", keygenCmd.Args, string(out), err)
@@ -171,12 +178,11 @@ func CommonOpts(sshPath string, useDotSSH bool) ([]string, error) {
 		return nil, err
 	}
 	var opts []string
-	if runtime.GOOS == "windows" {
-		privateKeyPath = ioutilx.CanonicalWindowsPath(privateKeyPath)
-		opts = []string{fmt.Sprintf(`IdentityFile='%s'`, privateKeyPath)}
-	} else {
-		opts = []string{fmt.Sprintf(`IdentityFile="%s"`, privateKeyPath)}
+	idf, err := identityFileEntry(privateKeyPath)
+	if err != nil {
+		return nil, err
 	}
+	opts = []string{idf}
 
 	// Append all private keys corresponding to ~/.ssh/*.pub to keep old instances working
 	// that had been created before lima started using an internal identity.
@@ -207,11 +213,11 @@ func CommonOpts(sshPath string, useDotSSH bool) ([]string, error) {
 				// Fail on permission-related and other path errors
 				return nil, err
 			}
-			if runtime.GOOS == "windows" {
-				opts = append(opts, fmt.Sprintf(`IdentityFile='%s'`, privateKeyPath))
-			} else {
-				opts = append(opts, fmt.Sprintf(`IdentityFile="%s"`, privateKeyPath))
+			idf, err = identityFileEntry(privateKeyPath)
+			if err != nil {
+				return nil, err
 			}
+			opts = append(opts, idf)
 		}
 	}
 
@@ -256,6 +262,17 @@ func CommonOpts(sshPath string, useDotSSH bool) ([]string, error) {
 	return opts, nil
 }
 
+func identityFileEntry(privateKeyPath string) (string, error) {
+	if runtime.GOOS == "windows" {
+		privateKeyPath, err := ioutilx.WindowsSubsystemPath(privateKeyPath)
+		if err != nil {
+			return "", err
+		}
+		return fmt.Sprintf(`IdentityFile='%s'`, privateKeyPath), nil
+	}
+	return fmt.Sprintf(`IdentityFile="%s"`, privateKeyPath), nil
+}
+
 // SSHOpts adds the following options to CommonOptions: User, ControlMaster, ControlPath, ControlPersist.
 func SSHOpts(sshPath, instDir, username string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) {
 	controlSock := filepath.Join(instDir, filenames.SSHSock)
@@ -268,7 +285,10 @@ func SSHOpts(sshPath, instDir, username string, useDotSSH, forwardAgent, forward
 	}
 	controlPath := fmt.Sprintf(`ControlPath="%s"`, controlSock)
 	if runtime.GOOS == "windows" {
-		controlSock = ioutilx.CanonicalWindowsPath(controlSock)
+		controlSock, err = ioutilx.WindowsSubsystemPath(controlSock)
+		if err != nil {
+			return nil, err
+		}
 		controlPath = fmt.Sprintf(`ControlPath='%s'`, controlSock)
 	}
 	opts = append(opts,


Test patch:
diff --git a/hack/test-templates.sh b/hack/test-templates.sh
index f0ec7263ae9..ead67b70401 100755
--- a/hack/test-templates.sh
+++ b/hack/test-templates.sh
@@ -229,8 +229,11 @@ tmpdir="$(mktemp -d "${TMPDIR:-/tmp}"/lima-test-templates.XXXXXX)"
 defer "rm -rf \"$tmpdir\""
 tmpfile="$tmpdir/lima-hostname"
 rm -f "$tmpfile"
-# TODO support Windows path https://github.com/lima-vm/lima/issues/3215
-limactl cp "$NAME":/etc/hostname "$tmpfile"
+tmpfile_host=$tmpfile
+if [ "${OS_HOST}" = "Msys" ]; then
+	tmpfile_host="$(cygpath -w "$tmpfile")"
+fi
+limactl cp "$NAME":/etc/hostname "$tmpfile_host"
 expected="$(limactl shell "$NAME" cat /etc/hostname)"
 got="$(cat "$tmpfile")"
 INFO "/etc/hostname: expected=${expected}, got=${got}"