pkg/ioutilx: move Windows path helpers to pkg/fsutil

WindowsSubsystemPath, WindowsSubsystemPathWithCygpath,
WindowsSubsystemPathForLinux, and the unexported
windowsSubsystemPathWithoutCygpath fallback do not resemble io/ioutil
and belong with the other filesystem helpers in pkg/fsutil, per #5283.
ReadAtMaximum and the UTF16 helpers stay in pkg/ioutilx; their eventual
home is still an open question in the issue.

The real implementation moves into pkg/fsutil/fsutil_windows.go, which
is only compiled on GOOS=windows per its filename suffix. All existing
callers (cmd/limactl/shell.go, pkg/copytool, pkg/hostagent,
pkg/sshutil, pkg/limayaml/defaults.go) are cross-platform files that
already gate these calls behind "runtime.GOOS == \"windows\""
checks, so the symbols still need to resolve at compile time on
non-Windows. pkg/fsutil/fsutil_unix.go (//go:build !windows) adds
stub definitions with matching signatures that return an error; they
are unreachable in practice since every call site is
runtime.GOOS-gated, mirroring the existing errNotSupported pattern in
pkg/guestagent/timesync/timesync_others.go. This keeps every caller's
code unchanged and keeps the diff minimal while still not breaking
any GOOS build.

The Windows-only unit test (pkg/ioutilx/ioutilx_windows_test.go) moves
alongside the code to pkg/fsutil/fsutil_windows_test.go, unchanged
apart from the package name.

No compatibility alias/stub was kept in pkg/ioutilx for the moved
symbols: recent pkg/ moves in this repo's history (e.g. the
sshutil/ioutilx cygpath work in 7a5a207 and 54db504) rewrite call
sites directly rather than leaving forwarding shims, so that
convention is followed here too.

Verified: go build ./... on darwin, GOOS=linux go build ./...,
GOOS=windows go build ./... (all clean); go test
./pkg/ioutilx/... ./pkg/fsutil/... plus the caller packages
(copytool, hostagent, sshutil, limayaml, cmd/limactl) on darwin;
GOOS=windows go vet/test -c on pkg/fsutil to compile-check the
Windows-only files; go vet ./... on darwin; golangci-lint run on all
touched packages (0 issues).

Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
This commit is contained in:
arimu1 committed 2026-07-19 06:45:09 +07:00
1 parent 596bc53e45
commit cf7b44a727
10 files changed
+128 -81

No files matched your search

+3 -3
View File
@@ -25,8 +25,8 @@ import (
"github.com/lima-vm/lima/v2/pkg/autostart"
"github.com/lima-vm/lima/v2/pkg/copytool"
"github.com/lima-vm/lima/v2/pkg/envutil"
"github.com/lima-vm/lima/v2/pkg/fsutil"
"github.com/lima-vm/lima/v2/pkg/instance"
"github.com/lima-vm/lima/v2/pkg/ioutilx"
"github.com/lima-vm/lima/v2/pkg/limatype"
"github.com/lima-vm/lima/v2/pkg/limayaml"
"github.com/lima-vm/lima/v2/pkg/localpathutil"
@@ -784,9 +784,9 @@ func rsyncDirectory(ctx context.Context, cmd *cobra.Command, rsync copytool.Copy
func mountDirFromWindowsDir(ctx context.Context, inst *limatype.Instance, dir string) (string, error) {
if inst.VMType == limatype.WSL2 {
distroName := "lima-" + inst.Name
return ioutilx.WindowsSubsystemPathForLinux(ctx, dir, distroName)
return fsutil.WindowsSubsystemPathForLinux(ctx, dir, distroName)
}
return ioutilx.WindowsSubsystemPath(ctx, dir)
return fsutil.WindowsSubsystemPath(ctx, dir)
}
func shellBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+2 -2
View File
@@ -15,7 +15,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/lima-vm/lima/v2/pkg/ioutilx"
"github.com/lima-vm/lima/v2/pkg/fsutil"
"github.com/lima-vm/lima/v2/pkg/limatype"
"github.com/lima-vm/lima/v2/pkg/store"
)
@@ -124,7 +124,7 @@ func parseCopyPaths(ctx context.Context, paths []string) ([]*Path, error) {
if runtime.GOOS == "windows" {
if filepath.IsAbs(path) {
var err error
path, err = ioutilx.WindowsSubsystemPath(ctx, path)
path, err = fsutil.WindowsSubsystemPath(ctx, path)
if err != nil {
return nil, err
}
+35
View File
@@ -0,0 +1,35 @@
//go:build !windows
// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0
package fsutil
import (
"context"
"errors"
)
// errWindowsPathConversionNotSupported is returned by the Windows path
// conversion stubs below. Callers only invoke them behind a
// runtime.GOOS == "windows" check, so this is never actually returned in
// practice; it exists so the fsutil_windows.go API still compiles here.
var errWindowsPathConversionNotSupported = errors.New("fsutil: Windows path conversion is not supported on this platform")
// WindowsSubsystemPath is the non-Windows stub for the Windows-only
// implementation in fsutil_windows.go.
func WindowsSubsystemPath(_ context.Context, _ string) (string, error) {
return "", errWindowsPathConversionNotSupported
}
// WindowsSubsystemPathWithCygpath is the non-Windows stub for the
// Windows-only implementation in fsutil_windows.go.
func WindowsSubsystemPathWithCygpath(_ context.Context, _, _ string) (string, error) {
return "", errWindowsPathConversionNotSupported
}
// WindowsSubsystemPathForLinux is the non-Windows stub for the Windows-only
// implementation in fsutil_windows.go.
func WindowsSubsystemPathForLinux(_ context.Context, _, _ string) (string, error) {
return "", errWindowsPathConversionNotSupported
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0
package fsutil
import (
"context"
"errors"
"fmt"
"io/fs"
"os/exec"
"path/filepath"
"strings"
"github.com/sirupsen/logrus"
)
// WindowsSubsystemPath converts a Windows path to a Cygwin/MSYS form
// (C:\Users\jan -> /c/Users/jan) via cygpath on PATH. A caller holding a
// specific ssh toolchain should use WindowsSubsystemPathWithCygpath, which
// runs that toolchain's own cygpath and respects its fstab.
func WindowsSubsystemPath(ctx context.Context, orig string) (string, error) {
return WindowsSubsystemPathWithCygpath(ctx, "cygpath", orig)
}
// WindowsSubsystemPathWithCygpath converts a Windows path with the given
// cygpathExe ("cygpath" for PATH, or an absolute path to bind the conversion
// to one Cygwin install). When the cygpath binary is not found it falls back
// to a native conversion of the absolute drive-letter case; a cygpath that
// runs and fails returns its error, and non-drive-letter inputs (UNC, device,
// extended-length) return an error.
func WindowsSubsystemPathWithCygpath(ctx context.Context, cygpathExe, orig string) (string, error) {
out, err := exec.CommandContext(ctx, cygpathExe, filepath.ToSlash(orig)).CombinedOutput()
if err == nil {
return strings.TrimSpace(string(out)), nil
}
if !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("failed to run %#q on %#q: %#q: %w", cygpathExe, orig, strings.TrimSpace(string(out)), err)
}
logrus.WithError(err).Debugf("%#q not found for %#q, attempting native conversion", cygpathExe, orig)
return windowsSubsystemPathWithoutCygpath(orig)
}
func windowsSubsystemPathWithoutCygpath(orig string) (string, error) {
// The /c/... form this produces is the MSYS2 and Git-for-Windows
// convention; stock Cygwin defaults to /cygdrive/c/. Only an absolute
// drive-letter path ("C:\foo") has a well-defined form here. A
// drive-relative path ("C:foo") would become an unrelated absolute
// path, so reject it.
if !filepath.IsAbs(orig) {
return "", fmt.Errorf("cannot convert %#q to an MSYS-style path: input is not an absolute drive-letter path", orig)
}
// UNC path ("\\server\share\foo") is rejected here.
if vol := filepath.VolumeName(orig); len(vol) == 2 && vol[1] == ':' {
// orig[2:] starts with a separator for an absolute drive path
// (C:\foo, C:/foo); strip it so the result stays canonical.
tail := strings.TrimPrefix(filepath.ToSlash(orig[2:]), "/")
converted := "/" + strings.ToLower(vol[:1]) + "/" + tail
logrus.Debugf("native cygpath fallback: %#q -> %#q", orig, converted)
return converted, nil
}
return "", fmt.Errorf("cannot convert %#q to an MSYS-style path: input is not an absolute drive-letter path", orig)
}
// WindowsSubsystemPathForLinux converts a Windows path to the WSL form of
// the given distro (e.g. C:\Users\jan -> /mnt/c/Users/jan) via wsl.exe.
func WindowsSubsystemPathForLinux(ctx context.Context, orig, distro string) (string, error) {
out, err := exec.CommandContext(ctx, "wsl", "-d", distro, "--exec", "wslpath", filepath.ToSlash(orig)).CombinedOutput()
if err != nil {
logrus.WithError(err).Errorf("failed to convert path to mingw, maybe wsl command is not operational?")
return "", err
}
return strings.TrimSpace(string(out)), nil
}
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0
package ioutilx
package fsutil
import (
"strings"
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"github.com/lima-vm/sshocker/pkg/reversesshfs"
"github.com/sirupsen/logrus"
"github.com/lima-vm/lima/v2/pkg/ioutilx"
"github.com/lima-vm/lima/v2/pkg/fsutil"
"github.com/lima-vm/lima/v2/pkg/limatype"
"github.com/lima-vm/lima/v2/pkg/sshutil"
)
@@ -55,7 +55,7 @@ func (a *HostAgent) setupMount(ctx context.Context, m limatype.Mount) (*mount, e
resolvedLocation := m.Location
if runtime.GOOS == "windows" {
var err error
resolvedLocation, err = ioutilx.WindowsSubsystemPath(ctx, m.Location)
resolvedLocation, err = fsutil.WindowsSubsystemPath(ctx, m.Location)
if err != nil {
return nil, err
}
-66
View File
@@ -4,16 +4,10 @@
package ioutilx
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os/exec"
"path/filepath"
"strings"
"github.com/sirupsen/logrus"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
@@ -50,63 +44,3 @@ func FromUTF16leToString(r io.Reader) (string, error) {
return string(out), nil
}
// WindowsSubsystemPath converts a Windows path to a Cygwin/MSYS form
// (C:\Users\jan -> /c/Users/jan) via cygpath on PATH. A caller holding a
// specific ssh toolchain should use WindowsSubsystemPathWithCygpath, which
// runs that toolchain's own cygpath and respects its fstab.
func WindowsSubsystemPath(ctx context.Context, orig string) (string, error) {
return WindowsSubsystemPathWithCygpath(ctx, "cygpath", orig)
}
// WindowsSubsystemPathWithCygpath converts a Windows path with the given
// cygpathExe ("cygpath" for PATH, or an absolute path to bind the conversion
// to one Cygwin install). When the cygpath binary is not found it falls back
// to a native conversion of the absolute drive-letter case; a cygpath that
// runs and fails returns its error, and non-drive-letter inputs (UNC, device,
// extended-length) return an error.
func WindowsSubsystemPathWithCygpath(ctx context.Context, cygpathExe, orig string) (string, error) {
out, err := exec.CommandContext(ctx, cygpathExe, filepath.ToSlash(orig)).CombinedOutput()
if err == nil {
return strings.TrimSpace(string(out)), nil
}
if !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("failed to run %#q on %#q: %#q: %w", cygpathExe, orig, strings.TrimSpace(string(out)), err)
}
logrus.WithError(err).Debugf("%#q not found for %#q, attempting native conversion", cygpathExe, orig)
return windowsSubsystemPathWithoutCygpath(orig)
}
func windowsSubsystemPathWithoutCygpath(orig string) (string, error) {
// The /c/... form this produces is the MSYS2 and Git-for-Windows
// convention; stock Cygwin defaults to /cygdrive/c/. Only an absolute
// drive-letter path ("C:\foo") has a well-defined form here. A
// drive-relative path ("C:foo") would become an unrelated absolute
// path, so reject it.
if !filepath.IsAbs(orig) {
return "", fmt.Errorf("cannot convert %#q to an MSYS-style path: input is not an absolute drive-letter path", orig)
}
// UNC path ("\\server\share\foo") is rejected here.
if vol := filepath.VolumeName(orig); len(vol) == 2 && vol[1] == ':' {
// orig[2:] starts with a separator for an absolute drive path
// (C:\foo, C:/foo); strip it so the result stays canonical.
tail := strings.TrimPrefix(filepath.ToSlash(orig[2:]), "/")
converted := "/" + strings.ToLower(vol[:1]) + "/" + tail
logrus.Debugf("native cygpath fallback: %#q -> %#q", orig, converted)
return converted, nil
}
return "", fmt.Errorf("cannot convert %#q to an MSYS-style path: input is not an absolute drive-letter path", orig)
}
func WindowsSubsystemPathForLinux(ctx context.Context, orig, distro string) (string, error) {
out, err := exec.CommandContext(ctx, "wsl", "-d", distro, "--exec", "wslpath", filepath.ToSlash(orig)).CombinedOutput()
if err != nil {
logrus.WithError(err).Errorf("failed to convert path to mingw, maybe wsl command is not operational?")
return "", err
}
return strings.TrimSpace(string(out)), nil
}
+2 -2
View File
@@ -26,8 +26,8 @@ import (
"github.com/pbnjay/memory"
"github.com/sirupsen/logrus"
"github.com/lima-vm/lima/v2/pkg/fsutil"
"github.com/lima-vm/lima/v2/pkg/instance/hostname"
"github.com/lima-vm/lima/v2/pkg/ioutilx"
"github.com/lima-vm/lima/v2/pkg/limatype"
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
@@ -729,7 +729,7 @@ func FillDefault(ctx context.Context, y, d, o *limatype.LimaYAML, filePath strin
mountLocation := mount.Location
if runtime.GOOS == "windows" {
var err error
mountLocation, err = ioutilx.WindowsSubsystemPath(ctx, mountLocation)
mountLocation, err = fsutil.WindowsSubsystemPath(ctx, mountLocation)
if err != nil {
logrus.WithError(err).Warnf("Couldn't convert location %#q into mount target", mount.Location)
}
+3 -3
View File
@@ -19,7 +19,7 @@ import (
"github.com/sirupsen/logrus"
"gotest.tools/v3/assert"
"github.com/lima-vm/lima/v2/pkg/ioutilx"
"github.com/lima-vm/lima/v2/pkg/fsutil"
"github.com/lima-vm/lima/v2/pkg/limatype"
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
@@ -240,7 +240,7 @@ func TestFillDefault(t *testing.T) {
expect.Mounts = slices.Clone(y.Mounts)
expect.Mounts[0].MountPoint = ptr.Of(expect.Mounts[0].Location)
if runtime.GOOS == "windows" {
mountLocation, err := ioutilx.WindowsSubsystemPath(t.Context(), expect.Mounts[0].Location)
mountLocation, err := fsutil.WindowsSubsystemPath(t.Context(), expect.Mounts[0].Location)
if err == nil {
expect.Mounts[0].MountPoint = ptr.Of(mountLocation)
}
@@ -477,7 +477,7 @@ func TestFillDefault(t *testing.T) {
expect.Mounts = slices.Clone(d.Mounts)
expect.Mounts[0].MountPoint = ptr.Of(expect.Mounts[0].Location)
if runtime.GOOS == "windows" {
mountLocation, err := ioutilx.WindowsSubsystemPath(t.Context(), expect.Mounts[0].Location)
mountLocation, err := fsutil.WindowsSubsystemPath(t.Context(), expect.Mounts[0].Location)
if err == nil {
expect.Mounts[0].MountPoint = ptr.Of(mountLocation)
}
+2 -2
View File
@@ -27,7 +27,7 @@ import (
"github.com/sirupsen/logrus"
"golang.org/x/sys/cpu"
"github.com/lima-vm/lima/v2/pkg/ioutilx"
"github.com/lima-vm/lima/v2/pkg/fsutil"
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
"github.com/lima-vm/lima/v2/pkg/lockutil"
@@ -232,7 +232,7 @@ func PathForSSH(ctx context.Context, sshExe SSHExe, orig string) (string, error)
return orig, nil
}
if cygpathExe, ok := cygpathForSSH(sshExe); ok {
return ioutilx.WindowsSubsystemPathWithCygpath(ctx, cygpathExe, orig)
return fsutil.WindowsSubsystemPathWithCygpath(ctx, cygpathExe, orig)
}
return filepath.ToSlash(orig), nil
}