Skip to content

Commit 2b2e22c

Browse files
committed
Windows host & guest scaffolding: pure-Go cygpath replacement, WINDOWS OS schema, experimental template
Two complementary primary-goal contributions for the LFX 2026 Term 2 project "Improve Windows support (host and guest)" (#4907): 1. Windows host UX Replaces the cygpath.exe subprocess at pkg/ioutilx/ioutilx.go:54 with deterministic pure-Go path translation. The implicit Cygwin / MSYS2 dependency is removed from Windows hosts. The public signature of WindowsSubsystemPath is unchanged, so all eight production callers (cmd/limactl/shell.go, pkg/copytool, pkg/hostagent/mount, pkg/sshutil x3, pkg/limayaml/defaults) continue to work without edits. - detectSubsystemStyle() picks one of {native, msys, cygwin} from MSYSTEM, CYGWIN, the SSH env var, and the resolved ssh binary path. Env vars win over heuristics so a user-asserted MSYSTEM is honored. - convertWindowsSubsystemPath() does the namespace remapping with pure string operations (no path/filepath calls), so the package builds and tests on any host. UNC paths pass through with slashes normalized, matching cygpath -u's behavior. - WindowsSubsystemPathForLinux on line 62 is intentionally left alone; it shells out to "wsl --exec wslpath", which is correct and not a Cygwin dependency. - 23 sub-tests cover style detection, drive-letter and UNC conversion, and the public end-to-end entry point. Uses gotest.tools/v3/assert per Lima's .golangci.yml. 2. Windows guest scaffolding Adds the schema groundwork any Windows-guest provisioning route needs: - WINDOWS OS = "Windows" added to pkg/limatype/lima_yaml.go and the OSTypes slice. NewOS() normalizes the lowercase "windows" alias the same way it does "linux" and "darwin". - pkg/limayaml/validate.go switch on *y.OS whitelists Windows. - TestValidateMultipleErrors updated to use "plan9" as the rejected OS (was using "windows" as the invalid example, which is now valid) and refreshed the expected OSTypes message. - TestValidateWindowsGuestOS added — positive + negative cases. - templates/experimental/windows.yaml — minimal QEMU-driven Windows guest template. Image URL is intentionally a 404 placeholder so the template is reviewable for its schema shape without misleading anyone into thinking limactl start is functional yet; the file header documents the follow-up work in pkg/cidata and pkg/driver/qemu. A self-contained Go module under poc/ prototypes the conversion logic in isolation and is kept in the tree as a runnable demo (go run ./poc/cmd/winpath-demo 'C:\...'). It is not required by the production code path and maintainers may strip it before merge. Out of scope, deferred to follow-up PRs and documented in the project plan: pkg/cidata branching for Cloudbase-Init data, QEMU TPM/UEFI plumbing for Windows guests, pkg/driver/hcs external driver, WSL2 multi-instance + WSLg, limactl doctor for the first- run Windows wizard. Signed-off-by: mn-ram <235066282+mn-ram@users.noreply.github.com>
1 parent 5a20c6d commit 2b2e22c

11 files changed

Lines changed: 900 additions & 10 deletions

File tree

pkg/ioutilx/ioutilx.go

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"io"
11+
"os"
1112
"os/exec"
1213
"path/filepath"
1314
"strings"
@@ -50,13 +51,124 @@ func FromUTF16leToString(r io.Reader) (string, error) {
5051
return string(out), nil
5152
}
5253

53-
func WindowsSubsystemPath(ctx context.Context, orig string) (string, error) {
54-
out, err := exec.CommandContext(ctx, "cygpath", filepath.ToSlash(orig)).CombinedOutput()
55-
if err != nil {
56-
logrus.WithError(err).Errorf("failed to convert path to mingw, maybe not using Git ssh?")
57-
return "", err
54+
// WindowsSubsystemPath converts a Windows path into the form expected by
55+
// whichever SSH client Lima is about to invoke. It replaces a former
56+
// cygpath.exe subprocess; the conversion is now pure-Go and deterministic,
57+
// so it no longer depends on Cygwin/MSYS2 being installed on the host.
58+
//
59+
// Three target styles are picked from the environment:
60+
// - Win32-OpenSSH (default) → native path, slashes normalized.
61+
// - MSYS2 / Git-Bash (MSYSTEM set) → "/c/Users/me/...".
62+
// - Cygwin (CYGWIN set) → "/cygdrive/c/Users/me/...".
63+
//
64+
// The ctx parameter is accepted for signature compatibility with the
65+
// previous subprocess-based implementation; no syscall is performed.
66+
func WindowsSubsystemPath(_ context.Context, orig string) (string, error) {
67+
return convertWindowsSubsystemPath(detectSubsystemStyle(os.Getenv, exec.LookPath), orig)
68+
}
69+
70+
// subsystemStyle is the target SSH-client path namespace.
71+
type subsystemStyle int
72+
73+
const (
74+
subsystemNative subsystemStyle = iota // Win32-OpenSSH
75+
subsystemMSYS // MSYS2 / MSYS / Git-Bash
76+
subsystemCygwin // Cygwin
77+
)
78+
79+
// detectSubsystemStyle picks the style the downstream ssh binary expects.
80+
// Order: explicit env vars first (user-asserted intent), then a heuristic
81+
// over the resolved ssh binary path. getenv and lookPath are injected so
82+
// tests can drive every branch without touching the real environment.
83+
func detectSubsystemStyle(getenv func(string) string, lookPath func(string) (string, error)) subsystemStyle {
84+
if getenv("MSYSTEM") != "" {
85+
return subsystemMSYS
5886
}
59-
return strings.TrimSpace(string(out)), nil
87+
if getenv("CYGWIN") != "" {
88+
return subsystemCygwin
89+
}
90+
sshPath := getenv("SSH")
91+
if sshPath == "" && lookPath != nil {
92+
if p, err := lookPath("ssh"); err == nil {
93+
sshPath = p
94+
}
95+
}
96+
if sshPath == "" {
97+
return subsystemNative
98+
}
99+
low := strings.ToLower(strings.ReplaceAll(sshPath, `\`, `/`))
100+
switch {
101+
case strings.Contains(low, "/cygwin"):
102+
return subsystemCygwin
103+
case strings.Contains(low, "/git/usr/bin/"),
104+
strings.Contains(low, "/msys64/"),
105+
strings.Contains(low, "/msys32/"),
106+
strings.Contains(low, "/mingw64/"),
107+
strings.Contains(low, "/mingw32/"):
108+
return subsystemMSYS
109+
default:
110+
// Win32-OpenSSH typically lives under
111+
// C:\Windows\System32\OpenSSH\ssh.exe.
112+
return subsystemNative
113+
}
114+
}
115+
116+
// convertWindowsSubsystemPath translates an absolute Windows-style path
117+
// into the requested style. Pure string logic, no path/filepath calls, so
118+
// this is testable on any host (filepath's Windows semantics only kick in
119+
// when GOOS=windows). UNC inputs pass through with slashes normalized,
120+
// matching cygpath -u's behavior for UNC paths.
121+
func convertWindowsSubsystemPath(style subsystemStyle, orig string) (string, error) {
122+
vol := windowsVolumeName(orig)
123+
124+
// UNC (\\server\share\...): preserve structure, normalize slashes.
125+
if strings.HasPrefix(vol, `\\`) || strings.HasPrefix(vol, `//`) {
126+
return strings.ReplaceAll(orig, `\`, `/`), nil
127+
}
128+
129+
// Not a drive-letter path: return as-is (slash-normalized).
130+
if len(vol) < 2 {
131+
return strings.ReplaceAll(orig, `\`, `/`), nil
132+
}
133+
134+
drive := strings.ToLower(string(vol[0]))
135+
rest := strings.ReplaceAll(orig[len(vol):], `\`, `/`)
136+
if !strings.HasPrefix(rest, "/") {
137+
rest = "/" + rest
138+
}
139+
140+
switch style {
141+
case subsystemMSYS:
142+
return "/" + drive + rest, nil
143+
case subsystemCygwin:
144+
return "/cygdrive/" + drive + rest, nil
145+
default:
146+
return strings.ToUpper(string(vol[0])) + ":" + rest, nil
147+
}
148+
}
149+
150+
// windowsVolumeName mirrors filepath.VolumeName's behavior for Windows
151+
// inputs, but works regardless of GOOS. Recognizes drive letters ("C:")
152+
// and UNC prefixes (\\server\share). Returns "" for anything else.
153+
func windowsVolumeName(p string) string {
154+
if len(p) >= 2 && p[1] == ':' &&
155+
((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z')) {
156+
return p[:2]
157+
}
158+
if len(p) >= 2 && (p[0] == '\\' || p[0] == '/') && (p[1] == '\\' || p[1] == '/') {
159+
rest := p[2:]
160+
serverEnd := strings.IndexAny(rest, `\/`)
161+
if serverEnd < 0 {
162+
return p
163+
}
164+
shareRest := rest[serverEnd+1:]
165+
shareEnd := strings.IndexAny(shareRest, `\/`)
166+
if shareEnd < 0 {
167+
return p
168+
}
169+
return p[:2+serverEnd+1+shareEnd]
170+
}
171+
return ""
60172
}
61173

62174
func WindowsSubsystemPathForLinux(ctx context.Context, orig, distro string) (string, error) {

pkg/ioutilx/ioutilx_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package ioutilx
5+
6+
import (
7+
"errors"
8+
"testing"
9+
10+
"gotest.tools/v3/assert"
11+
)
12+
13+
func TestDetectSubsystemStyle(t *testing.T) {
14+
cases := []struct {
15+
name string
16+
env map[string]string
17+
lookPath func(string) (string, error)
18+
want subsystemStyle
19+
}{
20+
{
21+
name: "MSYSTEM set wins",
22+
env: map[string]string{"MSYSTEM": "MINGW64"},
23+
want: subsystemMSYS,
24+
},
25+
{
26+
name: "CYGWIN set wins when MSYSTEM unset",
27+
env: map[string]string{"CYGWIN": "nodosfilewarning"},
28+
want: subsystemCygwin,
29+
},
30+
{
31+
name: "SSH env points at cygwin install",
32+
env: map[string]string{"SSH": `C:\cygwin64\bin\ssh.exe`},
33+
want: subsystemCygwin,
34+
},
35+
{
36+
name: "SSH env points at Git for Windows",
37+
env: map[string]string{"SSH": `C:\Program Files\Git\usr\bin\ssh.exe`},
38+
want: subsystemMSYS,
39+
},
40+
{
41+
name: "SSH env points at Win32-OpenSSH",
42+
env: map[string]string{"SSH": `C:\Windows\System32\OpenSSH\ssh.exe`},
43+
want: subsystemNative,
44+
},
45+
{
46+
name: "LookPath fallback finds Git ssh",
47+
env: map[string]string{},
48+
lookPath: func(string) (string, error) { return `C:\Program Files\Git\usr\bin\ssh.exe`, nil },
49+
want: subsystemMSYS,
50+
},
51+
{
52+
name: "LookPath fails, no env hints, defaults to native",
53+
env: map[string]string{},
54+
lookPath: func(string) (string, error) { return "", errors.New("not found") },
55+
want: subsystemNative,
56+
},
57+
{
58+
name: "empty env defaults to native",
59+
env: map[string]string{},
60+
want: subsystemNative,
61+
},
62+
}
63+
for _, tc := range cases {
64+
t.Run(tc.name, func(t *testing.T) {
65+
getenv := func(k string) string { return tc.env[k] }
66+
assert.Equal(t, detectSubsystemStyle(getenv, tc.lookPath), tc.want)
67+
})
68+
}
69+
}
70+
71+
func TestConvertWindowsSubsystemPath(t *testing.T) {
72+
cases := []struct {
73+
name string
74+
style subsystemStyle
75+
input string
76+
want string
77+
}{
78+
{"C drive to MSYS", subsystemMSYS, `C:\Users\me\.lima`, "/c/Users/me/.lima"},
79+
{"C drive to Cygwin", subsystemCygwin, `C:\Users\me\.lima`, "/cygdrive/c/Users/me/.lima"},
80+
{"C drive to Native is slash-normalized", subsystemNative, `C:\Users\me\.lima`, "C:/Users/me/.lima"},
81+
{"D drive lowercased for MSYS", subsystemMSYS, `D:\data`, "/d/data"},
82+
{"Root of drive", subsystemCygwin, `C:\`, "/cygdrive/c/"},
83+
{"UNC passes through normalized", subsystemMSYS, `\\fileserver\share\dir`, "//fileserver/share/dir"},
84+
{"Already-slashed input is preserved", subsystemMSYS, `C:/Users/me`, "/c/Users/me"},
85+
}
86+
for _, tc := range cases {
87+
t.Run(tc.name, func(t *testing.T) {
88+
got, err := convertWindowsSubsystemPath(tc.style, tc.input)
89+
assert.NilError(t, err)
90+
assert.Equal(t, got, tc.want)
91+
})
92+
}
93+
}
94+
95+
// TestWindowsSubsystemPath_EndToEnd exercises the public function — the
96+
// one all eight production call sites (cmd/limactl/shell.go,
97+
// pkg/copytool, pkg/hostagent/mount, pkg/sshutil, pkg/limayaml/defaults)
98+
// hit. It asserts no subprocess is required by passing nil context and
99+
// confirms the output matches what cygpath -u would have produced.
100+
func TestWindowsSubsystemPath_EndToEnd(t *testing.T) {
101+
t.Setenv("MSYSTEM", "")
102+
t.Setenv("CYGWIN", "")
103+
t.Setenv("SSH", `C:\Windows\System32\OpenSSH\ssh.exe`)
104+
105+
got, err := WindowsSubsystemPath(t.Context(), `C:\Users\me\.lima\_config\user`)
106+
assert.NilError(t, err)
107+
assert.Equal(t, got, "C:/Users/me/.lima/_config/user")
108+
}
109+
110+
func TestWindowsVolumeName(t *testing.T) {
111+
cases := []struct {
112+
input string
113+
want string
114+
}{
115+
{`C:\foo`, `C:`},
116+
{`c:`, `c:`},
117+
{`/foo/bar`, ``},
118+
{`relative/path`, ``},
119+
{`\\server\share\dir`, `\\server\share`},
120+
{`//server/share/dir`, `//server/share`},
121+
{``, ``},
122+
}
123+
for _, tc := range cases {
124+
t.Run(tc.input, func(t *testing.T) {
125+
assert.Equal(t, windowsVolumeName(tc.input), tc.want)
126+
})
127+
}
128+
}

pkg/limatype/lima_yaml.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const (
8080
LINUX OS = "Linux"
8181
DARWIN OS = "Darwin"
8282
FREEBSD OS = "FreeBSD"
83+
WINDOWS OS = "Windows"
8384

8485
X8664 Arch = "x86_64"
8586
AARCH64 Arch = "aarch64"
@@ -99,7 +100,7 @@ const (
99100
)
100101

101102
var (
102-
OSTypes = []OS{LINUX, DARWIN, FREEBSD}
103+
OSTypes = []OS{LINUX, DARWIN, FREEBSD, WINDOWS}
103104
ArchTypes = []Arch{X8664, AARCH64, ARMV7L, PPC64LE, RISCV64, S390X}
104105
MountTypes = []MountType{REVSSHFS, NINEP, VIRTIOFS, WSLMount}
105106
VMTypes = []VMType{QEMU, VZ, WSL2}
@@ -348,6 +349,8 @@ func NewOS(osname string) OS {
348349
return LINUX
349350
case "darwin":
350351
return DARWIN
352+
case "windows":
353+
return WINDOWS
351354
default:
352355
logrus.Warnf("Unknown os: %s", osname)
353356
return osname

pkg/limayaml/validate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func Validate(y *limatype.LimaYAML, warn bool) error {
5050
}
5151

5252
switch *y.OS {
53-
case limatype.LINUX, limatype.DARWIN, limatype.FREEBSD:
53+
case limatype.LINUX, limatype.DARWIN, limatype.FREEBSD, limatype.WINDOWS:
5454
default:
5555
errs = errors.Join(errs, fmt.Errorf("field `os` must be one of %q; got %q", limatype.OSTypes, *y.OS))
5656
}

pkg/limayaml/validate_test.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ func TestValidateParamIsUsed(t *testing.T) {
350350

351351
func TestValidateMultipleErrors(t *testing.T) {
352352
yamlWithMultipleErrors := `
353-
os: windows
353+
os: plan9
354354
arch: unsupported_arch
355355
portForwards:
356356
- guestPort: 22
@@ -369,13 +369,43 @@ provision:
369369
err = Validate(y, false)
370370
t.Logf("Validation errors: %v", err)
371371

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

379+
// TestValidateWindowsGuestOS verifies the schema addition from the LFX
380+
// 2026 Term 2 Windows-support work: os: Windows is now an accepted value,
381+
// laying the groundwork for Cloudbase-Init or autounattend.xml first-boot
382+
// provisioning in pkg/cidata (follow-up). See lima-vm/lima#4907.
383+
func TestValidateWindowsGuestOS(t *testing.T) {
384+
tests := []struct {
385+
name string
386+
osValue string
387+
wantErr string
388+
}{
389+
{name: "Windows accepted", osValue: "Windows", wantErr: ""},
390+
{name: "Linux still accepted", osValue: "Linux", wantErr: ""},
391+
{name: "Unknown OS still rejected", osValue: "plan9", wantErr: "got \"plan9\""},
392+
}
393+
for _, tt := range tests {
394+
t.Run(tt.name, func(t *testing.T) {
395+
y, err := Load(t.Context(),
396+
[]byte("os: "+tt.osValue+"\nimages: [{\"location\": \"/\"}]\n"),
397+
"windows-guest.yaml")
398+
assert.NilError(t, err)
399+
err = Validate(y, false)
400+
if tt.wantErr == "" {
401+
assert.NilError(t, err)
402+
} else {
403+
assert.ErrorContains(t, err, tt.wantErr)
404+
}
405+
})
406+
}
407+
}
408+
379409
func TestValidateAgainstLatestConfig(t *testing.T) {
380410
tests := []struct {
381411
name string

0 commit comments

Comments
 (0)