diff --git a/internal/computestorage/computestorage.go b/internal/computestorage/computestorage.go new file mode 100644 index 00000000..e0f5ab7c --- /dev/null +++ b/internal/computestorage/computestorage.go @@ -0,0 +1,48 @@ +//go:build windows + +package computestorage + +import ( + "fmt" + + "golang.org/x/sys/windows" + + "github.com/Microsoft/go-winio/internal/interop" +) + +//go:generate go run github.com/Microsoft/go-winio/tools/mkwinsyscall -output zsyscall_windows.go computestorage.go + +// https://learn.microsoft.com/en-us/virtualization/api/hcs/reference/hcsformatwritablelayervhd +// +//sys hcsFormatWritableLayerVhd(handle windows.Handle) (hr error) = computestorage.HcsFormatWritableLayerVhd? + +// FormatWritableLayerVHD formats a virtual disk for use as a writable container layer. +// +// If the VHD is not mounted it will be temporarily mounted. +// +// NOTE: This API had a breaking change in the operating system after Windows Server 2019. +// On ws2019 the API expects to get passed a file handle from CreateFile for the vhd that +// the caller wants to format. On > ws2019, its expected that the caller passes a vhd handle +// that can be obtained from the virtdisk APIs. +func FormatWritableLayerVHD(vhdHandle windows.Handle) (err error) { + err = hcsFormatWritableLayerVhd(vhdHandle) + if err != nil { + return fmt.Errorf("failed to format writable layer vhd: %w", err) + } + return nil +} + +// https://learn.microsoft.com/en-us/virtualization/api/hcs/reference/hcsgetlayervhdmountpath +// +//sys hcsGetLayerVhdMountPath(vhdHandle windows.Handle, mountPath **uint16) (hr error) = computestorage.HcsGetLayerVhdMountPath? + +// GetLayerVHDMountPath returns the volume path for a virtual disk of a writable container layer. +func GetLayerVHDMountPath(vhdHandle windows.Handle) (path string, err error) { + var mountPath *uint16 + err = hcsGetLayerVhdMountPath(vhdHandle, &mountPath) + if err != nil { + return "", fmt.Errorf("failed to get vhd mount path: %w", err) + } + path = interop.ConvertAndFreeCoTaskMemString(mountPath) + return path, nil +} diff --git a/internal/computestorage/doc.go b/internal/computestorage/doc.go new file mode 100644 index 00000000..d333186f --- /dev/null +++ b/internal/computestorage/doc.go @@ -0,0 +1 @@ +package computestorage diff --git a/internal/computestorage/zsyscall_windows.go b/internal/computestorage/zsyscall_windows.go new file mode 100644 index 00000000..e20baff7 --- /dev/null +++ b/internal/computestorage/zsyscall_windows.go @@ -0,0 +1,77 @@ +//go:build windows + +// Code generated by 'go generate' using "github.com/Microsoft/go-winio/tools/mkwinsyscall"; DO NOT EDIT. + +package computestorage + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var _ unsafe.Pointer + +// Do the interface allocations only once for common +// Errno values. +const ( + errnoERROR_IO_PENDING = 997 +) + +var ( + errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING) + errERROR_EINVAL error = syscall.EINVAL +) + +// errnoErr returns common boxed Errno values, to prevent +// allocations at runtime. +func errnoErr(e syscall.Errno) error { + switch e { + case 0: + return errERROR_EINVAL + case errnoERROR_IO_PENDING: + return errERROR_IO_PENDING + } + // TODO: add more here, after collecting data on the common + // error values see on Windows. (perhaps when running + // all.bat?) + return e +} + +var ( + modcomputestorage = windows.NewLazySystemDLL("computestorage.dll") + + procHcsFormatWritableLayerVhd = modcomputestorage.NewProc("HcsFormatWritableLayerVhd") + procHcsGetLayerVhdMountPath = modcomputestorage.NewProc("HcsGetLayerVhdMountPath") +) + +func hcsFormatWritableLayerVhd(handle windows.Handle) (hr error) { + hr = procHcsFormatWritableLayerVhd.Find() + if hr != nil { + return + } + r0, _, _ := syscall.Syscall(procHcsFormatWritableLayerVhd.Addr(), 1, uintptr(handle), 0, 0) + if int32(r0) < 0 { + if r0&0x1fff0000 == 0x00070000 { + r0 &= 0xffff + } + hr = syscall.Errno(r0) + } + return +} + +func hcsGetLayerVhdMountPath(vhdHandle windows.Handle, mountPath **uint16) (hr error) { + hr = procHcsGetLayerVhdMountPath.Find() + if hr != nil { + return + } + r0, _, _ := syscall.Syscall(procHcsGetLayerVhdMountPath.Addr(), 2, uintptr(vhdHandle), uintptr(unsafe.Pointer(mountPath)), 0) + if int32(r0) < 0 { + if r0&0x1fff0000 == 0x00070000 { + r0 &= 0xffff + } + hr = syscall.Errno(r0) + } + return +} diff --git a/internal/interop/doc.go b/internal/interop/doc.go new file mode 100644 index 00000000..cb554867 --- /dev/null +++ b/internal/interop/doc.go @@ -0,0 +1 @@ +package interop diff --git a/internal/interop/interop.go b/internal/interop/interop.go new file mode 100644 index 00000000..a5646965 --- /dev/null +++ b/internal/interop/interop.go @@ -0,0 +1,25 @@ +//go:build windows + +package interop + +import ( + "syscall" + "unsafe" +) + +//go:generate go run github.com/Microsoft/go-winio/tools/mkwinsyscall -output zsyscall_windows.go interop.go + +//sys coTaskMemFree(buffer unsafe.Pointer) = api_ms_win_core_com_l1_1_0.CoTaskMemFree + +func ConvertAndFreeCoTaskMemString(buffer *uint16) string { + str := syscall.UTF16ToString((*[1 << 29]uint16)(unsafe.Pointer(buffer))[:]) + coTaskMemFree(unsafe.Pointer(buffer)) + return str +} + +func Win32FromHresult(hr uintptr) syscall.Errno { + if hr&0x1fff0000 == 0x00070000 { + return syscall.Errno(hr & 0xffff) + } + return syscall.Errno(hr) +} diff --git a/internal/interop/zsyscall_windows.go b/internal/interop/zsyscall_windows.go new file mode 100644 index 00000000..a17a1125 --- /dev/null +++ b/internal/interop/zsyscall_windows.go @@ -0,0 +1,51 @@ +//go:build windows + +// Code generated by 'go generate' using "github.com/Microsoft/go-winio/tools/mkwinsyscall"; DO NOT EDIT. + +package interop + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var _ unsafe.Pointer + +// Do the interface allocations only once for common +// Errno values. +const ( + errnoERROR_IO_PENDING = 997 +) + +var ( + errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING) + errERROR_EINVAL error = syscall.EINVAL +) + +// errnoErr returns common boxed Errno values, to prevent +// allocations at runtime. +func errnoErr(e syscall.Errno) error { + switch e { + case 0: + return errERROR_EINVAL + case errnoERROR_IO_PENDING: + return errERROR_IO_PENDING + } + // TODO: add more here, after collecting data on the common + // error values see on Windows. (perhaps when running + // all.bat?) + return e +} + +var ( + modapi_ms_win_core_com_l1_1_0 = windows.NewLazySystemDLL("api-ms-win-core-com-l1-1-0.dll") + + procCoTaskMemFree = modapi_ms_win_core_com_l1_1_0.NewProc("CoTaskMemFree") +) + +func coTaskMemFree(buffer unsafe.Pointer) { + syscall.Syscall(procCoTaskMemFree.Addr(), 1, uintptr(buffer), 0, 0) + return +} diff --git a/pkg/fs/resolve.go b/pkg/fs/resolve.go index b876c4c0..fd64f48c 100644 --- a/pkg/fs/resolve.go +++ b/pkg/fs/resolve.go @@ -20,28 +20,9 @@ import ( // It is intended to address short-comings of [filepath.EvalSymlinks], which does not work // well on Windows. func ResolvePath(path string) (string, error) { - // We are not able to use builtin Go functionality for opening a directory path: - // - os.Open on a directory returns a os.File where Fd() is a search handle from FindFirstFile. - // - syscall.Open does not provide a way to specify FILE_FLAG_BACKUP_SEMANTICS, which is needed to - // open a directory. - // - // We could use os.Open if the path is a file, but it's easier to just use the same code for both. - // Therefore, we call windows.CreateFile directly. - h, err := fs.CreateFile( - path, - fs.FILE_ANY_ACCESS, // access - fs.FILE_SHARE_READ|fs.FILE_SHARE_WRITE|fs.FILE_SHARE_DELETE, - nil, // security attributes - fs.OPEN_EXISTING, - fs.FILE_FLAG_BACKUP_SEMANTICS, // Needed to open a directory handle. - fs.NullHandle, // template file - ) + h, err := openMetadata(path) if err != nil { - return "", &os.PathError{ - Op: "CreateFile", - Path: path, - Err: err, - } + return "", err } defer windows.CloseHandle(h) //nolint:errcheck @@ -126,3 +107,33 @@ func ResolvePath(path string) (string, error) { } return rPath, err } + +// openMetadata takes a path, opens it with only meta-data access, and returns the resulting handle. +// It works for both file and directory paths. +func openMetadata(path string) (windows.Handle, error) { + // We are not able to use builtin Go functionality for opening a directory path: + // - os.Open on a directory returns a os.File where Fd() is a search handle from FindFirstFile. + // - syscall.Open does not provide a way to specify FILE_FLAG_BACKUP_SEMANTICS, which is needed to + // open a directory. + // + // We could use os.Open if the path is a file, but it's easier to just use the same code for both. + // Therefore, we call windows.CreateFile directly. + h, err := fs.CreateFile( + path, + fs.FILE_ANY_ACCESS, + fs.FILE_SHARE_READ|fs.FILE_SHARE_WRITE|fs.FILE_SHARE_DELETE, + nil, // security attributes + fs.OPEN_EXISTING, + fs.FILE_FLAG_BACKUP_SEMANTICS, // Needed to open a directory handle. + fs.NullHandle, + ) + + if err != nil { + return 0, &os.PathError{ + Op: "CreateFile", + Path: path, + Err: err, + } + } + return h, nil +} diff --git a/pkg/fs/resolve_test.go b/pkg/fs/resolve_test.go new file mode 100644 index 00000000..d82ba586 --- /dev/null +++ b/pkg/fs/resolve_test.go @@ -0,0 +1,222 @@ +//go:build windows + +package fs + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "golang.org/x/sys/windows" + + "github.com/Microsoft/go-winio/internal/computestorage" + "github.com/Microsoft/go-winio/internal/fs" + "github.com/Microsoft/go-winio/vhd" +) + +func getWindowsBuildNumber() uint32 { + // RtlGetVersion ignores manifest requirements + vex := windows.RtlGetVersion() + return vex.BuildNumber +} + +func makeSymlink(t *testing.T, oldName string, newName string) { + if err := os.Symlink(oldName, newName); err != nil { + t.Fatalf("creating symlink: %s", err) + } +} + +func getVolumeGUIDPath(t *testing.T, path string) string { + h, err := openMetadata(path) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(h) //nolint:errcheck + final, err := fs.GetFinalPathNameByHandle(h, fs.FILE_NAME_OPENED|fs.VOLUME_NAME_GUID) + if err != nil { + t.Fatal(err) + } + return final +} + +func openDisk(path string) (windows.Handle, error) { + h, err := fs.CreateFile( + path, + windows.GENERIC_READ|windows.GENERIC_WRITE, + fs.FILE_SHARE_READ|fs.FILE_SHARE_WRITE, + nil, // security attributes + fs.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|fs.FILE_FLAG_NO_BUFFERING, + fs.NullHandle) + if err != nil { + return 0, &os.PathError{ + Op: "CreateFile", + Path: path, + Err: err, + } + } + return h, nil +} + +func formatVHD(vhdHandle windows.Handle) error { + h := vhdHandle + // Pre-19H1 HcsFormatWritableLayerVhd expects a disk handle. + // On newer builds it expects a VHD handle instead. + // Open a handle to the VHD's disk object if needed. + + // Windows Server 1903, aka 19H1 + if getWindowsBuildNumber() < 18362 { + diskPath, err := vhd.GetVirtualDiskPhysicalPath(syscall.Handle(h)) + if err != nil { + return err + } + diskHandle, err := openDisk(diskPath) + if err != nil { + return err + } + defer windows.CloseHandle(diskHandle) //nolint:errcheck // cleanup code + h = diskHandle + } + // Formatting a disk directly in Windows is a pain, so we use FormatWritableLayerVhd to do it. + // It has a side effect of creating a sandbox directory on the formatted volume, but it's safe + // to just ignore that for our purposes here. + return computestorage.FormatWritableLayerVHD(h) +} + +// Creates a VHD with a NTFS volume. Returns the volume path. +func setupVHDVolume(t *testing.T, vhdPath string) string { + vhdHandle, err := vhd.CreateVirtualDisk(vhdPath, + vhd.VirtualDiskAccessNone, vhd.CreateVirtualDiskFlagNone, + &vhd.CreateVirtualDiskParameters{ + Version: 2, + Version2: vhd.CreateVersion2{ + MaximumSize: 5 * 1024 * 1024 * 1024, // 5GB, thin provisioned + BlockSizeInBytes: 1 * 1024 * 1024, // 1MB + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = windows.CloseHandle(windows.Handle(vhdHandle)) + }) + if err := vhd.AttachVirtualDisk(vhdHandle, vhd.AttachVirtualDiskFlagNone, &vhd.AttachVirtualDiskParameters{Version: 1}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := vhd.DetachVirtualDisk(vhdHandle); err != nil { + t.Fatal(err) + } + }) + if err := formatVHD(windows.Handle(vhdHandle)); err != nil { + t.Fatalf("failed to format VHD: %s", err) + } + // Get the path for the volume that was just created on the disk. + volumePath, err := computestorage.GetLayerVHDMountPath(windows.Handle(vhdHandle)) + if err != nil { + t.Fatal(err) + } + return volumePath +} + +func writeFile(t *testing.T, path string, content []byte) { + if err := os.WriteFile(path, content, 0644); err != nil { //nolint:gosec // test file, can have permissive mode + t.Fatal(err) + } +} + +func mountVolume(t *testing.T, volumePath string, mountPoint string) { + // Create the mount point directory. + if err := os.Mkdir(mountPoint, 0644); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Remove(mountPoint); err != nil { + t.Fatal(err) + } + }) + // Volume path must end in a slash. + if !strings.HasSuffix(volumePath, `\`) { + volumePath += `\` + } + volumePathU16, err := windows.UTF16PtrFromString(volumePath) + if err != nil { + t.Fatal(err) + } + // Mount point must end in a slash. + if !strings.HasSuffix(mountPoint, `\`) { + mountPoint += `\` + } + mountPointU16, err := windows.UTF16PtrFromString(mountPoint) + if err != nil { + t.Fatal(err) + } + if err := windows.SetVolumeMountPoint(mountPointU16, volumePathU16); err != nil { + t.Fatalf("failed to mount %s onto %s: %s", volumePath, mountPoint, err) + } + t.Cleanup(func() { + if err := windows.DeleteVolumeMountPoint(mountPointU16); err != nil { + t.Fatalf("failed to delete mount on %s: %s", mountPoint, err) + } + }) +} + +func TestResolvePath(t *testing.T) { + if !windows.GetCurrentProcessToken().IsElevated() { + t.Fatal("test requires elevated privileges") + } + + // Set up some data to be used by the test cases. + volumePathC := getVolumeGUIDPath(t, `C:\`) + dir := t.TempDir() + + makeSymlink(t, `C:\windows`, filepath.Join(dir, "lnk1")) + makeSymlink(t, `\\localhost\c$\windows`, filepath.Join(dir, "lnk2")) + + volumePathVHD1 := setupVHDVolume(t, filepath.Join(dir, "foo.vhdx")) + writeFile(t, filepath.Join(volumePathVHD1, "data.txt"), []byte("test content 1")) + makeSymlink(t, filepath.Join(volumePathVHD1, "data.txt"), filepath.Join(dir, "lnk3")) + + volumePathVHD2 := setupVHDVolume(t, filepath.Join(dir, "bar.vhdx")) + writeFile(t, filepath.Join(volumePathVHD2, "data.txt"), []byte("test content 2")) + makeSymlink(t, filepath.Join(volumePathVHD2, "data.txt"), filepath.Join(dir, "lnk4")) + mountVolume(t, volumePathVHD2, filepath.Join(dir, "mnt")) + + for _, tc := range []struct { + input string + expected string + description string + }{ + {`C:\windows`, volumePathC + `Windows`, "local path"}, + {filepath.Join(dir, "lnk1"), volumePathC + `Windows`, "symlink to local path"}, + {`\\localhost\c$\windows`, `\\localhost\c$\Windows`, "UNC path"}, + {filepath.Join(dir, "lnk2"), `\\localhost\c$\Windows`, "symlink to UNC path"}, + {filepath.Join(volumePathVHD1, "data.txt"), filepath.Join(volumePathVHD1, "data.txt"), "volume with no mount point"}, + {filepath.Join(dir, "lnk3"), filepath.Join(volumePathVHD1, "data.txt"), "symlink to volume with no mount point"}, + {filepath.Join(dir, "mnt", "data.txt"), filepath.Join(volumePathVHD2, "data.txt"), "volume with mount point"}, + {filepath.Join(dir, "lnk4"), filepath.Join(volumePathVHD2, "data.txt"), "symlink to volume with mount point"}, + } { + t.Run(tc.description, func(t *testing.T) { + actual, err := ResolvePath(tc.input) + if err != nil { + t.Fatalf("resolvePath should return no error, but %v", err) + } + if actual != tc.expected { + t.Fatalf("expected %v but got %v", tc.expected, actual) + } + // Make sure EvalSymlinks works with the resolved path, as an extra safety measure. + p, err := filepath.EvalSymlinks(actual) + if err != nil { + t.Fatalf("EvalSymlinks should return no error, but %v", err) + } + // As an extra-extra safety, check that resolvePath(x) == EvalSymlinks(resolvePath(x)). + // EvalSymlinks normalizes UNC path casing, but resolvePath may not, so compare with + // case-insensitivity here. + if !strings.EqualFold(actual, p) { + t.Fatalf("EvalSymlinks should resolve to the same path. Expected %v but got %v", actual, p) + } + }) + } +}