path/filepath: add IsLocal

IsLocal reports whether a path lexically refers to a location
contained within the directory in which it is evaluated.
It identifies paths that are absolute, escape a directory
with ".." elements, and (on Windows) paths that reference
reserved device names.

For #56219.

Change-Id: I35edfa3ce77b40b8e66f1fc8e0ff73cfd06f2313
Reviewed-on: https://go-review.googlesource.com/c/go/+/449239
Run-TryBot: Damien Neil <dneil@google.com>
Reviewed-by: Joseph Tsai <joetsai@digital-static.net>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Ian Lance Taylor <iant@google.com>
Reviewed-by: Ian Lance Taylor <iant@golang.org>
Reviewed-by: Joedian Reid <joedian@golang.org>
This commit is contained in:
Damien Neil 2022-11-09 17:49:44 -08:00
parent fd59c6cf8c
commit 6d0bf438e3
7 changed files with 177 additions and 0 deletions

1
api/next/56219.txt Normal file
View file

@ -0,0 +1 @@
pkg path/filepath, func IsLocal(string) bool #56219

View file

@ -665,6 +665,13 @@ proxyHandler := &httputil.ReverseProxy{
<p><!-- CL 363814 --><!-- https://go.dev/issue/47209 -->
TODO: <a href="https://go.dev/cl/363814">https://go.dev/cl/363814</a>: path/filepath, io/fs: add SkipAll; modified api/next/47209.txt
</p>
<p><!-- https://go.dev/issue/56219 -->
The new <code>IsLocal</code> function reports whether a path is
lexically local to a directory.
For example, if <code>IsLocal(p)</code> is <code>true</code>,
then <code>Open(p)</code> will refer to a file that is lexically
within the subtree rooted at the current directory.
</p>
</dd>
</dl><!-- io -->

View file

@ -172,6 +172,46 @@ func Clean(path string) string {
return FromSlash(out.string())
}
// IsLocal reports whether path, using lexical analysis only, has all of these properties:
//
// - is within the subtree rooted at the directory in which path is evaluated
// - is not an absolute path
// - is not empty
// - on Windows, is not a reserved name such as "NUL"
//
// If IsLocal(path) returns true, then
// Join(base, path) will always produce a path contained within base and
// Clean(path) will always produce an unrooted path with no ".." path elements.
//
// IsLocal is a purely lexical operation.
// In particular, it does not account for the effect of any symbolic links
// that may exist in the filesystem.
func IsLocal(path string) bool {
return isLocal(path)
}
func unixIsLocal(path string) bool {
if IsAbs(path) || path == "" {
return false
}
hasDots := false
for p := path; p != ""; {
var part string
part, p, _ = strings.Cut(p, "/")
if part == "." || part == ".." {
hasDots = true
break
}
}
if hasDots {
path = Clean(path)
}
if path == ".." || strings.HasPrefix(path, "../") {
return false
}
return true
}
// ToSlash returns the result of replacing each separator character
// in path with a slash ('/') character. Multiple separators are
// replaced by multiple slashes.

View file

@ -6,6 +6,10 @@ package filepath
import "strings"
func isLocal(path string) bool {
return unixIsLocal(path)
}
// IsAbs reports whether the path is absolute.
func IsAbs(path string) bool {
return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "#")

View file

@ -143,6 +143,60 @@ func TestClean(t *testing.T) {
}
}
type IsLocalTest struct {
path string
isLocal bool
}
var islocaltests = []IsLocalTest{
{"", false},
{".", true},
{"..", false},
{"../a", false},
{"/", false},
{"/a", false},
{"/a/../..", false},
{"a", true},
{"a/../a", true},
{"a/", true},
{"a/.", true},
{"a/./b/./c", true},
}
var winislocaltests = []IsLocalTest{
{"NUL", false},
{"nul", false},
{"nul.", false},
{"nul.txt", false},
{"com1", false},
{"./nul", false},
{"a/nul.txt/b", false},
{`\`, false},
{`\a`, false},
{`C:`, false},
{`C:\a`, false},
{`..\a`, false},
}
var plan9islocaltests = []IsLocalTest{
{"#a", false},
}
func TestIsLocal(t *testing.T) {
tests := islocaltests
if runtime.GOOS == "windows" {
tests = append(tests, winislocaltests...)
}
if runtime.GOOS == "plan9" {
tests = append(tests, plan9islocaltests...)
}
for _, test := range tests {
if got := filepath.IsLocal(test.path); got != test.isLocal {
t.Errorf("IsLocal(%q) = %v, want %v", test.path, got, test.isLocal)
}
}
}
const sep = filepath.Separator
var slashtests = []PathTest{

View file

@ -8,6 +8,10 @@ package filepath
import "strings"
func isLocal(path string) bool {
return unixIsLocal(path)
}
// IsAbs reports whether the path is absolute.
func IsAbs(path string) bool {
return strings.HasPrefix(path, "/")

View file

@ -20,6 +20,73 @@ func toUpper(c byte) byte {
return c
}
// isReservedName reports if name is a Windows reserved device name.
// It does not detect names with an extension, which are also reserved on some Windows versions.
//
// For details, search for PRN in
// https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file.
func isReservedName(name string) bool {
if 3 <= len(name) && len(name) <= 4 {
switch string([]byte{toUpper(name[0]), toUpper(name[1]), toUpper(name[2])}) {
case "CON", "PRN", "AUX", "NUL":
return len(name) == 3
case "COM", "LPT":
return len(name) == 4 && '1' <= name[3] && name[3] <= '9'
}
}
return false
}
func isLocal(path string) bool {
if path == "" {
return false
}
if isSlash(path[0]) {
// Path rooted in the current drive.
return false
}
if strings.IndexByte(path, ':') >= 0 {
// Colons are only valid when marking a drive letter ("C:foo").
// Rejecting any path with a colon is conservative but safe.
return false
}
hasDots := false // contains . or .. path elements
for p := path; p != ""; {
var part string
part, p, _ = cutPath(p)
if part == "." || part == ".." {
hasDots = true
}
// Trim the extension and look for a reserved name.
base, _, hasExt := strings.Cut(part, ".")
if isReservedName(base) {
if !hasExt {
return false
}
// The path element is a reserved name with an extension. Some Windows
// versions consider this a reserved name, while others do not. Use
// FullPath to see if the name is reserved.
//
// FullPath will convert references to reserved device names to their
// canonical form: \\.\${DEVICE_NAME}
//
// FullPath does not perform this conversion for paths which contain
// a reserved device name anywhere other than in the last element,
// so check the part rather than the full path.
if p, _ := syscall.FullPath(part); len(p) >= 4 && p[:4] == `\\.\` {
return false
}
}
}
if hasDots {
path = Clean(path)
}
if path == ".." || strings.HasPrefix(path, `..\`) {
return false
}
return true
}
// IsAbs reports whether the path is absolute.
func IsAbs(path string) (b bool) {
l := volumeNameLen(path)