github.com/Serizao/go-winio@v0.0.0-20230906082528-f02f7f4ad6e8/pkg/fs/resolve.go (about)

     1  //go:build windows
     2  
     3  package fs
     4  
     5  import (
     6  	"errors"
     7  	"os"
     8  	"strings"
     9  
    10  	"golang.org/x/sys/windows"
    11  
    12  	"github.com/Serizao/go-winio/internal/fs"
    13  )
    14  
    15  // ResolvePath returns the final path to a file or directory represented, resolving symlinks,
    16  // handling mount points, etc.
    17  // The resolution works by using the Windows API GetFinalPathNameByHandle, which takes a
    18  // handle and returns the final path to that file.
    19  //
    20  // It is intended to address short-comings of [filepath.EvalSymlinks], which does not work
    21  // well on Windows.
    22  func ResolvePath(path string) (string, error) {
    23  	h, err := openMetadata(path)
    24  	if err != nil {
    25  		return "", err
    26  	}
    27  	defer windows.CloseHandle(h) //nolint:errcheck
    28  
    29  	// We use the Windows API GetFinalPathNameByHandle to handle path resolution. GetFinalPathNameByHandle
    30  	// returns a resolved path name for a file or directory. The returned path can be in several different
    31  	// formats, based on the flags passed. There are several goals behind the design here:
    32  	// - Do as little manual path manipulation as possible. Since Windows path formatting can be quite
    33  	//   complex, we try to just let the Windows APIs handle that for us.
    34  	// - Retain as much compatibility with existing Go path functions as we can. In particular, we try to
    35  	//   ensure paths returned from resolvePath can be passed to EvalSymlinks.
    36  	//
    37  	// First, we query for the VOLUME_NAME_GUID path of the file. This will return a path in the form
    38  	// "\\?\Volume{8a25748f-cf34-4ac6-9ee2-c89400e886db}\dir\file.txt". If the path is a UNC share
    39  	// (e.g. "\\server\share\dir\file.txt"), then the VOLUME_NAME_GUID query will fail with ERROR_PATH_NOT_FOUND.
    40  	// In this case, we will next try a VOLUME_NAME_DOS query. This query will return a path for a UNC share
    41  	// in the form "\\?\UNC\server\share\dir\file.txt". This path will work with most functions, but EvalSymlinks
    42  	// fails on it. Therefore, we rewrite the path to the form "\\server\share\dir\file.txt" before returning it.
    43  	// This path rewrite may not be valid in all cases (see the notes in the next paragraph), but those should
    44  	// be very rare edge cases, and this case wouldn't have worked with EvalSymlinks anyways.
    45  	//
    46  	// The "\\?\" prefix indicates that no path parsing or normalization should be performed by Windows.
    47  	// Instead the path is passed directly to the object manager. The lack of parsing means that "." and ".." are
    48  	// interpreted literally and "\"" must be used as a path separator. Additionally, because normalization is
    49  	// not done, certain paths can only be represented in this format. For instance, "\\?\C:\foo." (with a trailing .)
    50  	// cannot be written as "C:\foo.", because path normalization will remove the trailing ".".
    51  	//
    52  	// FILE_NAME_NORMALIZED can fail on some UNC paths based on access restrictions.
    53  	// Attempt to query with FILE_NAME_NORMALIZED, and then fall back on FILE_NAME_OPENED if access is denied.
    54  	//
    55  	// Querying for VOLUME_NAME_DOS first instead of VOLUME_NAME_GUID would yield a "nicer looking" path in some cases.
    56  	// For instance, it could return "\\?\C:\dir\file.txt" instead of "\\?\Volume{8a25748f-cf34-4ac6-9ee2-c89400e886db}\dir\file.txt".
    57  	// However, we query for VOLUME_NAME_GUID first for two reasons:
    58  	// - The volume GUID path is more stable. A volume's mount point can change when it is remounted, but its
    59  	//   volume GUID should not change.
    60  	// - If the volume is mounted at a non-drive letter path (e.g. mounted to "C:\mnt"), then VOLUME_NAME_DOS
    61  	//   will return the mount path. EvalSymlinks fails on a path like this due to a bug.
    62  	//
    63  	// References:
    64  	// - GetFinalPathNameByHandle: https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlea
    65  	// - Naming Files, Paths, and Namespaces: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
    66  	// - Naming a Volume: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-volume
    67  
    68  	normalize := true
    69  	guid := true
    70  	rPath := ""
    71  	for i := 1; i <= 4; i++ { // maximum of 4 different cases to try
    72  		var flags fs.GetFinalPathFlag
    73  		if normalize {
    74  			flags |= fs.FILE_NAME_NORMALIZED // nop; for clarity
    75  		} else {
    76  			flags |= fs.FILE_NAME_OPENED
    77  		}
    78  
    79  		if guid {
    80  			flags |= fs.VOLUME_NAME_GUID
    81  		} else {
    82  			flags |= fs.VOLUME_NAME_DOS // nop; for clarity
    83  		}
    84  
    85  		rPath, err = fs.GetFinalPathNameByHandle(h, flags)
    86  		switch {
    87  		case guid && errors.Is(err, windows.ERROR_PATH_NOT_FOUND):
    88  			// ERROR_PATH_NOT_FOUND is returned from the VOLUME_NAME_GUID query if the path is a
    89  			// network share (UNC path). In this case, query for the DOS name instead.
    90  			guid = false
    91  			continue
    92  		case normalize && errors.Is(err, windows.ERROR_ACCESS_DENIED):
    93  			// normalization failed when accessing individual components along path for SMB share
    94  			normalize = false
    95  			continue
    96  		default:
    97  		}
    98  		break
    99  	}
   100  
   101  	if err == nil && strings.HasPrefix(rPath, `\\?\UNC\`) {
   102  		// Convert \\?\UNC\server\share -> \\server\share. The \\?\UNC syntax does not work with
   103  		// some Go filepath functions such as EvalSymlinks. In the future if other components
   104  		// move away from EvalSymlinks and use GetFinalPathNameByHandle instead, we could remove
   105  		// this path munging.
   106  		rPath = `\\` + rPath[len(`\\?\UNC\`):]
   107  	}
   108  	return rPath, err
   109  }
   110  
   111  // openMetadata takes a path, opens it with only meta-data access, and returns the resulting handle.
   112  // It works for both file and directory paths.
   113  func openMetadata(path string) (windows.Handle, error) {
   114  	// We are not able to use builtin Go functionality for opening a directory path:
   115  	//   - os.Open on a directory returns a os.File where Fd() is a search handle from FindFirstFile.
   116  	//   - syscall.Open does not provide a way to specify FILE_FLAG_BACKUP_SEMANTICS, which is needed to
   117  	//     open a directory.
   118  	//
   119  	// We could use os.Open if the path is a file, but it's easier to just use the same code for both.
   120  	// Therefore, we call windows.CreateFile directly.
   121  	h, err := fs.CreateFile(
   122  		path,
   123  		fs.FILE_ANY_ACCESS,
   124  		fs.FILE_SHARE_READ|fs.FILE_SHARE_WRITE|fs.FILE_SHARE_DELETE,
   125  		nil, // security attributes
   126  		fs.OPEN_EXISTING,
   127  		fs.FILE_FLAG_BACKUP_SEMANTICS, // Needed to open a directory handle.
   128  		fs.NullHandle,
   129  	)
   130  
   131  	if err != nil {
   132  		return 0, &os.PathError{
   133  			Op:   "CreateFile",
   134  			Path: path,
   135  			Err:  err,
   136  		}
   137  	}
   138  	return h, nil
   139  }