Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 118 additions & 6 deletions pkg/ioutilx/ioutilx.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
Expand Down Expand Up @@ -50,13 +51,124 @@ func FromUTF16leToString(r io.Reader) (string, error) {
return string(out), nil
}

func WindowsSubsystemPath(ctx context.Context, orig string) (string, error) {
out, err := exec.CommandContext(ctx, "cygpath", filepath.ToSlash(orig)).CombinedOutput()
if err != nil {
logrus.WithError(err).Errorf("failed to convert path to mingw, maybe not using Git ssh?")
return "", err
// WindowsSubsystemPath converts a Windows path into the form expected by
// whichever SSH client Lima is about to invoke. It replaces a former
// cygpath.exe subprocess; the conversion is now pure-Go and deterministic,
// so it no longer depends on Cygwin/MSYS2 being installed on the host.
//
// Three target styles are picked from the environment:
// - Win32-OpenSSH (default) → native path, slashes normalized.
// - MSYS2 / Git-Bash (MSYSTEM set) → "/c/Users/me/...".
// - Cygwin (CYGWIN set) → "/cygdrive/c/Users/me/...".
//
// The ctx parameter is accepted for signature compatibility with the
// previous subprocess-based implementation; no syscall is performed.
func WindowsSubsystemPath(_ context.Context, orig string) (string, error) {
return convertWindowsSubsystemPath(detectSubsystemStyle(os.Getenv, exec.LookPath), orig)
}

// subsystemStyle is the target SSH-client path namespace.
type subsystemStyle int

const (
subsystemNative subsystemStyle = iota // Win32-OpenSSH
subsystemMSYS // MSYS2 / MSYS / Git-Bash
subsystemCygwin // Cygwin
)

// detectSubsystemStyle picks the style the downstream ssh binary expects.
// Order: explicit env vars first (user-asserted intent), then a heuristic
// over the resolved ssh binary path. getenv and lookPath are injected so
// tests can drive every branch without touching the real environment.
func detectSubsystemStyle(getenv func(string) string, lookPath func(string) (string, error)) subsystemStyle {
if getenv("MSYSTEM") != "" {
return subsystemMSYS
}
return strings.TrimSpace(string(out)), nil
if getenv("CYGWIN") != "" {
return subsystemCygwin
}
sshPath := getenv("SSH")
if sshPath == "" && lookPath != nil {
if p, err := lookPath("ssh"); err == nil {
sshPath = p
}
}
if sshPath == "" {
return subsystemNative
}
low := strings.ToLower(strings.ReplaceAll(sshPath, `\`, `/`))
switch {
case strings.Contains(low, "/cygwin"):
return subsystemCygwin
case strings.Contains(low, "/git/usr/bin/"),
strings.Contains(low, "/msys64/"),
strings.Contains(low, "/msys32/"),
strings.Contains(low, "/mingw64/"),
strings.Contains(low, "/mingw32/"):
return subsystemMSYS
default:
// Win32-OpenSSH typically lives under
// C:\Windows\System32\OpenSSH\ssh.exe.
return subsystemNative
}
}

// convertWindowsSubsystemPath translates an absolute Windows-style path
// into the requested style. Pure string logic, no path/filepath calls, so
// this is testable on any host (filepath's Windows semantics only kick in
// when GOOS=windows). UNC inputs pass through with slashes normalized,
// matching cygpath -u's behavior for UNC paths.
func convertWindowsSubsystemPath(style subsystemStyle, orig string) (string, error) {
vol := windowsVolumeName(orig)

// UNC (\\server\share\...): preserve structure, normalize slashes.
if strings.HasPrefix(vol, `\\`) || strings.HasPrefix(vol, `//`) {
return strings.ReplaceAll(orig, `\`, `/`), nil
}

// Not a drive-letter path: return as-is (slash-normalized).
if len(vol) < 2 {
return strings.ReplaceAll(orig, `\`, `/`), nil
}

drive := strings.ToLower(string(vol[0]))
rest := strings.ReplaceAll(orig[len(vol):], `\`, `/`)
if !strings.HasPrefix(rest, "/") {
rest = "/" + rest
}

switch style {
case subsystemMSYS:
return "/" + drive + rest, nil
case subsystemCygwin:
return "/cygdrive/" + drive + rest, nil
default:
return strings.ToUpper(string(vol[0])) + ":" + rest, nil
}
}

// windowsVolumeName mirrors filepath.VolumeName's behavior for Windows
// inputs, but works regardless of GOOS. Recognizes drive letters ("C:")
// and UNC prefixes (\\server\share). Returns "" for anything else.
func windowsVolumeName(p string) string {
if len(p) >= 2 && p[1] == ':' &&
((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z')) {
return p[:2]
}
if len(p) >= 2 && (p[0] == '\\' || p[0] == '/') && (p[1] == '\\' || p[1] == '/') {
rest := p[2:]
serverEnd := strings.IndexAny(rest, `\/`)
if serverEnd < 0 {
return p
}
shareRest := rest[serverEnd+1:]
shareEnd := strings.IndexAny(shareRest, `\/`)
if shareEnd < 0 {
return p
}
return p[:2+serverEnd+1+shareEnd]
}
return ""
}

func WindowsSubsystemPathForLinux(ctx context.Context, orig, distro string) (string, error) {
Expand Down
128 changes: 128 additions & 0 deletions pkg/ioutilx/ioutilx_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0

package ioutilx

import (
"errors"
"testing"

"gotest.tools/v3/assert"
)

func TestDetectSubsystemStyle(t *testing.T) {
cases := []struct {
name string
env map[string]string
lookPath func(string) (string, error)
want subsystemStyle
}{
{
name: "MSYSTEM set wins",
env: map[string]string{"MSYSTEM": "MINGW64"},
want: subsystemMSYS,
},
{
name: "CYGWIN set wins when MSYSTEM unset",
env: map[string]string{"CYGWIN": "nodosfilewarning"},
want: subsystemCygwin,
},
{
name: "SSH env points at cygwin install",
env: map[string]string{"SSH": `C:\cygwin64\bin\ssh.exe`},
want: subsystemCygwin,
},
{
name: "SSH env points at Git for Windows",
env: map[string]string{"SSH": `C:\Program Files\Git\usr\bin\ssh.exe`},
want: subsystemMSYS,
},
{
name: "SSH env points at Win32-OpenSSH",
env: map[string]string{"SSH": `C:\Windows\System32\OpenSSH\ssh.exe`},
want: subsystemNative,
},
{
name: "LookPath fallback finds Git ssh",
env: map[string]string{},
lookPath: func(string) (string, error) { return `C:\Program Files\Git\usr\bin\ssh.exe`, nil },
want: subsystemMSYS,
},
{
name: "LookPath fails, no env hints, defaults to native",
env: map[string]string{},
lookPath: func(string) (string, error) { return "", errors.New("not found") },
want: subsystemNative,
},
{
name: "empty env defaults to native",
env: map[string]string{},
want: subsystemNative,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
getenv := func(k string) string { return tc.env[k] }
assert.Equal(t, detectSubsystemStyle(getenv, tc.lookPath), tc.want)
})
}
}

func TestConvertWindowsSubsystemPath(t *testing.T) {
cases := []struct {
name string
style subsystemStyle
input string
want string
}{
{"C drive to MSYS", subsystemMSYS, `C:\Users\me\.lima`, "/c/Users/me/.lima"},
{"C drive to Cygwin", subsystemCygwin, `C:\Users\me\.lima`, "/cygdrive/c/Users/me/.lima"},
{"C drive to Native is slash-normalized", subsystemNative, `C:\Users\me\.lima`, "C:/Users/me/.lima"},
{"D drive lowercased for MSYS", subsystemMSYS, `D:\data`, "/d/data"},
{"Root of drive", subsystemCygwin, `C:\`, "/cygdrive/c/"},
{"UNC passes through normalized", subsystemMSYS, `\\fileserver\share\dir`, "//fileserver/share/dir"},
{"Already-slashed input is preserved", subsystemMSYS, `C:/Users/me`, "/c/Users/me"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := convertWindowsSubsystemPath(tc.style, tc.input)
assert.NilError(t, err)
assert.Equal(t, got, tc.want)
})
}
}

// TestWindowsSubsystemPath_EndToEnd exercises the public function — the
// one all eight production call sites (cmd/limactl/shell.go,
// pkg/copytool, pkg/hostagent/mount, pkg/sshutil, pkg/limayaml/defaults)
// hit. It asserts no subprocess is required by passing nil context and
// confirms the output matches what cygpath -u would have produced.
func TestWindowsSubsystemPath_EndToEnd(t *testing.T) {
t.Setenv("MSYSTEM", "")
t.Setenv("CYGWIN", "")
t.Setenv("SSH", `C:\Windows\System32\OpenSSH\ssh.exe`)

got, err := WindowsSubsystemPath(t.Context(), `C:\Users\me\.lima\_config\user`)
assert.NilError(t, err)
assert.Equal(t, got, "C:/Users/me/.lima/_config/user")
}

func TestWindowsVolumeName(t *testing.T) {
cases := []struct {
input string
want string
}{
{`C:\foo`, `C:`},
{`c:`, `c:`},
{`/foo/bar`, ``},
{`relative/path`, ``},
{`\\server\share\dir`, `\\server\share`},
{`//server/share/dir`, `//server/share`},
{``, ``},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
assert.Equal(t, windowsVolumeName(tc.input), tc.want)
})
}
}
5 changes: 4 additions & 1 deletion pkg/limatype/lima_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const (
LINUX OS = "Linux"
DARWIN OS = "Darwin"
FREEBSD OS = "FreeBSD"
WINDOWS OS = "Windows"

X8664 Arch = "x86_64"
AARCH64 Arch = "aarch64"
Expand All @@ -99,7 +100,7 @@ const (
)

var (
OSTypes = []OS{LINUX, DARWIN, FREEBSD}
OSTypes = []OS{LINUX, DARWIN, FREEBSD, WINDOWS}
ArchTypes = []Arch{X8664, AARCH64, ARMV7L, PPC64LE, RISCV64, S390X}
MountTypes = []MountType{REVSSHFS, NINEP, VIRTIOFS, WSLMount}
VMTypes = []VMType{QEMU, VZ, WSL2}
Expand Down Expand Up @@ -348,6 +349,8 @@ func NewOS(osname string) OS {
return LINUX
case "darwin":
return DARWIN
case "windows":
return WINDOWS
default:
logrus.Warnf("Unknown os: %s", osname)
return osname
Expand Down
2 changes: 1 addition & 1 deletion pkg/limayaml/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func Validate(y *limatype.LimaYAML, warn bool) error {
}

switch *y.OS {
case limatype.LINUX, limatype.DARWIN, limatype.FREEBSD:
case limatype.LINUX, limatype.DARWIN, limatype.FREEBSD, limatype.WINDOWS:
default:
errs = errors.Join(errs, fmt.Errorf("field `os` must be one of %q; got %q", limatype.OSTypes, *y.OS))
}
Expand Down
34 changes: 32 additions & 2 deletions pkg/limayaml/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func TestValidateParamIsUsed(t *testing.T) {

func TestValidateMultipleErrors(t *testing.T) {
yamlWithMultipleErrors := `
os: windows
os: plan9
arch: unsupported_arch
portForwards:
- guestPort: 22
Expand All @@ -369,13 +369,43 @@ provision:
err = Validate(y, false)
t.Logf("Validation errors: %v", err)

assert.Error(t, err, "field `os` must be one of [\"Linux\" \"Darwin\" \"FreeBSD\"]; got \"windows\"\n"+
assert.Error(t, err, "field `os` must be one of [\"Linux\" \"Darwin\" \"FreeBSD\" \"Windows\"]; got \"plan9\"\n"+
"field `arch` must be one of [x86_64 aarch64 armv7l ppc64le riscv64 s390x]; got \"unsupported_arch\"\n"+
"field `images` must be set\n"+
"field `provision[0].mode` must one of \"system\", \"user\", \"boot\", \"data\", \"dependency\", \"ansible\", or \"yq\"\n"+
"field `provision[1].path` must not be empty when mode is \"data\"")
}

// TestValidateWindowsGuestOS verifies the schema addition from the LFX
// 2026 Term 2 Windows-support work: os: Windows is now an accepted value,
// laying the groundwork for Cloudbase-Init or autounattend.xml first-boot
// provisioning in pkg/cidata (follow-up). See lima-vm/lima#4907.
func TestValidateWindowsGuestOS(t *testing.T) {
tests := []struct {
name string
osValue string
wantErr string
}{
{name: "Windows accepted", osValue: "Windows", wantErr: ""},
{name: "Linux still accepted", osValue: "Linux", wantErr: ""},
{name: "Unknown OS still rejected", osValue: "plan9", wantErr: "got \"plan9\""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
y, err := Load(t.Context(),
[]byte("os: "+tt.osValue+"\nimages: [{\"location\": \"/\"}]\n"),
"windows-guest.yaml")
assert.NilError(t, err)
err = Validate(y, false)
if tt.wantErr == "" {
assert.NilError(t, err)
} else {
assert.ErrorContains(t, err, tt.wantErr)
}
})
}
}

func TestValidateAgainstLatestConfig(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading