Skip to content

Commit b0d3b35

Browse files
committed
windows: Use GetFinalPathNameByHandle for ResolveSymbolicLink
This change splits the definition of pkg/cri/os.ResolveSymbolicLink by platform (windows/!windows), and switches to an alternate implementation for Windows. This aims to fix the issue described in #5405. The previous implementation which just called filepath.EvalSymlinks has historically had issues on Windows. One of these issues we were able to fix in Go, but EvalSymlinks's behavior is not well specified on Windows, and there could easily be more issues in the future, so it seems prudent to move to a separate implementation for Windows. The new implementation uses the Windows GetFinalPathNameByHandle API, which takes a handle to an open file or directory and some flags, and returns the "real" name for the object. See comments in the code for details on the implementation. I have tested this change with a variety of mounts and everything seems to work as expected. Functions that make incorrect assumptions on what a Windows path can look like may have some trouble with the \\?\ path syntax. For instance EvalSymlinks fails when given a \\?\UNC\ path. For this reason, the resolvePath implementation modifies the returned path to translate to the more common form (\\?\UNC\server\share -> \\server\share). Signed-off-by: Kevin Parsons <kevpar@microsoft.com>
1 parent cbdc094 commit b0d3b35

File tree

4 files changed

+453
-13
lines changed

4 files changed

+453
-13
lines changed

pkg/os/os.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"io"
2121
"io/ioutil"
2222
"os"
23-
"path/filepath"
2423

2524
"github.com/moby/sys/symlink"
2625
)
@@ -56,18 +55,6 @@ func (RealOS) Stat(name string) (os.FileInfo, error) {
5655
return os.Stat(name)
5756
}
5857

59-
// ResolveSymbolicLink will follow any symbolic links
60-
func (RealOS) ResolveSymbolicLink(path string) (string, error) {
61-
info, err := os.Lstat(path)
62-
if err != nil {
63-
return "", err
64-
}
65-
if info.Mode()&os.ModeSymlink != os.ModeSymlink {
66-
return path, nil
67-
}
68-
return filepath.EvalSymlinks(path)
69-
}
70-
7158
// FollowSymlinkInScope will call symlink.FollowSymlinkInScope.
7259
func (RealOS) FollowSymlinkInScope(path, scope string) (string, error) {
7360
return symlink.FollowSymlinkInScope(path, scope)

pkg/os/os_unix.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
package os
2020

2121
import (
22+
"os"
23+
"path/filepath"
24+
2225
"github.com/containerd/containerd/mount"
2326
)
2427

@@ -29,3 +32,15 @@ type UNIX interface {
2932
Unmount(target string) error
3033
LookupMount(path string) (mount.Info, error)
3134
}
35+
36+
// ResolveSymbolicLink will follow any symbolic links
37+
func (RealOS) ResolveSymbolicLink(path string) (string, error) {
38+
info, err := os.Lstat(path)
39+
if err != nil {
40+
return "", err
41+
}
42+
if info.Mode()&os.ModeSymlink != os.ModeSymlink {
43+
return path, nil
44+
}
45+
return filepath.EvalSymlinks(path)
46+
}

pkg/os/os_windows.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package os
18+
19+
import (
20+
"os"
21+
"strings"
22+
"sync"
23+
"unicode/utf16"
24+
25+
"golang.org/x/sys/windows"
26+
)
27+
28+
// openPath takes a path, opens it, and returns the resulting handle.
29+
// It works for both file and directory paths.
30+
//
31+
// We are not able to use builtin Go functionality for opening a directory path:
32+
// - os.Open on a directory returns a os.File where Fd() is a search handle from FindFirstFile.
33+
// - syscall.Open does not provide a way to specify FILE_FLAG_BACKUP_SEMANTICS, which is needed to
34+
// open a directory.
35+
// We could use os.Open if the path is a file, but it's easier to just use the same code for both.
36+
// Therefore, we call windows.CreateFile directly.
37+
func openPath(path string) (windows.Handle, error) {
38+
u16, err := windows.UTF16PtrFromString(path)
39+
if err != nil {
40+
return 0, err
41+
}
42+
h, err := windows.CreateFile(
43+
u16,
44+
0,
45+
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
46+
nil,
47+
windows.OPEN_EXISTING,
48+
windows.FILE_FLAG_BACKUP_SEMANTICS, // Needed to open a directory handle.
49+
0)
50+
if err != nil {
51+
return 0, &os.PathError{
52+
Op: "CreateFile",
53+
Path: path,
54+
Err: err,
55+
}
56+
}
57+
return h, nil
58+
}
59+
60+
// GetFinalPathNameByHandle flags.
61+
//nolint:golint
62+
const (
63+
cFILE_NAME_OPENED = 0x8
64+
65+
cVOLUME_NAME_DOS = 0x0
66+
cVOLUME_NAME_GUID = 0x1
67+
)
68+
69+
var pool = sync.Pool{
70+
New: func() interface{} {
71+
// Size of buffer chosen somewhat arbitrarily to accommodate a large number of path strings.
72+
// MAX_PATH (260) + size of volume GUID prefix (49) + null terminator = 310.
73+
b := make([]uint16, 310)
74+
return &b
75+
},
76+
}
77+
78+
// getFinalPathNameByHandle facilitates calling the Windows API GetFinalPathNameByHandle
79+
// with the given handle and flags. It transparently takes care of creating a buffer of the
80+
// correct size for the call.
81+
func getFinalPathNameByHandle(h windows.Handle, flags uint32) (string, error) {
82+
b := *(pool.Get().(*[]uint16))
83+
defer func() { pool.Put(&b) }()
84+
for {
85+
n, err := windows.GetFinalPathNameByHandle(h, &b[0], uint32(len(b)), flags)
86+
if err != nil {
87+
return "", err
88+
}
89+
// If the buffer wasn't large enough, n will be the total size needed (including null terminator).
90+
// Resize and try again.
91+
if n > uint32(len(b)) {
92+
b = make([]uint16, n)
93+
continue
94+
}
95+
// If the buffer is large enough, n will be the size not including the null terminator.
96+
// Convert to a Go string and return.
97+
return string(utf16.Decode(b[:n])), nil
98+
}
99+
}
100+
101+
// resolvePath implements path resolution for Windows. It attempts to return the "real" path to the
102+
// file or directory represented by the given path.
103+
// The resolution works by using the Windows API GetFinalPathNameByHandle, which takes a handle and
104+
// returns the final path to that file.
105+
func resolvePath(path string) (string, error) {
106+
h, err := openPath(path)
107+
if err != nil {
108+
return "", err
109+
}
110+
defer windows.CloseHandle(h)
111+
112+
// We use the Windows API GetFinalPathNameByHandle to handle path resolution. GetFinalPathNameByHandle
113+
// returns a resolved path name for a file or directory. The returned path can be in several different
114+
// formats, based on the flags passed. There are several goals behind the design here:
115+
// - Do as little manual path manipulation as possible. Since Windows path formatting can be quite
116+
// complex, we try to just let the Windows APIs handle that for us.
117+
// - Retain as much compatibility with existing Go path functions as we can. In particular, we try to
118+
// ensure paths returned from resolvePath can be passed to EvalSymlinks.
119+
//
120+
// First, we query for the VOLUME_NAME_GUID path of the file. This will return a path in the form
121+
// "\\?\Volume{8a25748f-cf34-4ac6-9ee2-c89400e886db}\dir\file.txt". If the path is a UNC share
122+
// (e.g. "\\server\share\dir\file.txt"), then the VOLUME_NAME_GUID query will fail with ERROR_PATH_NOT_FOUND.
123+
// In this case, we will next try a VOLUME_NAME_DOS query. This query will return a path for a UNC share
124+
// in the form "\\?\UNC\server\share\dir\file.txt". This path will work with most functions, but EvalSymlinks
125+
// fails on it. Therefore, we rewrite the path to the form "\\server\share\dir\file.txt" before returning it.
126+
// This path rewrite may not be valid in all cases (see the notes in the next paragraph), but those should
127+
// be very rare edge cases, and this case wouldn't have worked with EvalSymlinks anyways.
128+
//
129+
// The "\\?\" prefix indicates that no path parsing or normalization should be performed by Windows.
130+
// Instead the path is passed directly to the object manager. The lack of parsing means that "." and ".." are
131+
// interpreted literally and "\"" must be used as a path separator. Additionally, because normalization is
132+
// not done, certain paths can only be represented in this format. For instance, "\\?\C:\foo." (with a trailing .)
133+
// cannot be written as "C:\foo.", because path normalization will remove the trailing ".".
134+
//
135+
// We use FILE_NAME_OPENED instead of FILE_NAME_NORMALIZED, as FILE_NAME_NORMALIZED can fail on some
136+
// UNC paths based on access restrictions. The additional normalization done is also quite minimal in
137+
// most cases.
138+
//
139+
// Querying for VOLUME_NAME_DOS first instead of VOLUME_NAME_GUID would yield a "nicer looking" path in some cases.
140+
// For instance, it could return "\\?\C:\dir\file.txt" instead of "\\?\Volume{8a25748f-cf34-4ac6-9ee2-c89400e886db}\dir\file.txt".
141+
// However, we query for VOLUME_NAME_GUID first for two reasons:
142+
// - The volume GUID path is more stable. A volume's mount point can change when it is remounted, but its
143+
// volume GUID should not change.
144+
// - If the volume is mounted at a non-drive letter path (e.g. mounted to "C:\mnt"), then VOLUME_NAME_DOS
145+
// will return the mount path. EvalSymlinks fails on a path like this due to a bug.
146+
//
147+
// References:
148+
// - GetFinalPathNameByHandle: https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlea
149+
// - Naming Files, Paths, and Namespaces: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
150+
// - Naming a Volume: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-volume
151+
152+
rPath, err := getFinalPathNameByHandle(h, cFILE_NAME_OPENED|cVOLUME_NAME_GUID)
153+
if err == windows.ERROR_PATH_NOT_FOUND {
154+
// ERROR_PATH_NOT_FOUND is returned from the VOLUME_NAME_GUID query if the path is a
155+
// network share (UNC path). In this case, query for the DOS name instead, then translate
156+
// the returned path to make it more palatable to other path functions.
157+
rPath, err = getFinalPathNameByHandle(h, cFILE_NAME_OPENED|cVOLUME_NAME_DOS)
158+
if err != nil {
159+
return "", err
160+
}
161+
if strings.HasPrefix(rPath, `\\?\UNC\`) {
162+
// Convert \\?\UNC\server\share -> \\server\share. The \\?\UNC syntax does not work with
163+
// some Go filepath functions such as EvalSymlinks. In the future if other components
164+
// move away from EvalSymlinks and use GetFinalPathNameByHandle instead, we could remove
165+
// this path munging.
166+
rPath = `\\` + rPath[len(`\\?\UNC\`):]
167+
}
168+
} else if err != nil {
169+
return "", err
170+
}
171+
return rPath, nil
172+
}
173+
174+
// ResolveSymbolicLink will follow any symbolic links
175+
func (RealOS) ResolveSymbolicLink(path string) (string, error) {
176+
// filepath.EvalSymlinks does not work very well on Windows, so instead we resolve the path
177+
// via resolvePath which uses GetFinalPathNameByHandle. This returns either a path prefixed with `\\?\`,
178+
// or a remote share path in the form \\server\share. These should work with most Go and Windows APIs.
179+
return resolvePath(path)
180+
}

0 commit comments

Comments
 (0)