Skip to content

Commit 6d0bf43

Browse files
committed
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 <[email protected]> Reviewed-by: Joseph Tsai <[email protected]> TryBot-Result: Gopher Robot <[email protected]> Reviewed-by: Ian Lance Taylor <[email protected]> Reviewed-by: Ian Lance Taylor <[email protected]> Reviewed-by: Joedian Reid <[email protected]>
1 parent fd59c6c commit 6d0bf43

File tree

7 files changed

+177
-0
lines changed

7 files changed

+177
-0
lines changed

api/next/56219.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pkg path/filepath, func IsLocal(string) bool #56219

doc/go1.20.html

+7
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,13 @@ <h3 id="minor_library_changes">Minor changes to the library</h3>
665665
<p><!-- CL 363814 --><!-- https://go.dev/issue/47209 -->
666666
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
667667
</p>
668+
<p><!-- https://go.dev/issue/56219 -->
669+
The new <code>IsLocal</code> function reports whether a path is
670+
lexically local to a directory.
671+
For example, if <code>IsLocal(p)</code> is <code>true</code>,
672+
then <code>Open(p)</code> will refer to a file that is lexically
673+
within the subtree rooted at the current directory.
674+
</p>
668675
</dd>
669676
</dl><!-- io -->
670677

src/path/filepath/path.go

+40
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,46 @@ func Clean(path string) string {
172172
return FromSlash(out.string())
173173
}
174174

175+
// IsLocal reports whether path, using lexical analysis only, has all of these properties:
176+
//
177+
// - is within the subtree rooted at the directory in which path is evaluated
178+
// - is not an absolute path
179+
// - is not empty
180+
// - on Windows, is not a reserved name such as "NUL"
181+
//
182+
// If IsLocal(path) returns true, then
183+
// Join(base, path) will always produce a path contained within base and
184+
// Clean(path) will always produce an unrooted path with no ".." path elements.
185+
//
186+
// IsLocal is a purely lexical operation.
187+
// In particular, it does not account for the effect of any symbolic links
188+
// that may exist in the filesystem.
189+
func IsLocal(path string) bool {
190+
return isLocal(path)
191+
}
192+
193+
func unixIsLocal(path string) bool {
194+
if IsAbs(path) || path == "" {
195+
return false
196+
}
197+
hasDots := false
198+
for p := path; p != ""; {
199+
var part string
200+
part, p, _ = strings.Cut(p, "/")
201+
if part == "." || part == ".." {
202+
hasDots = true
203+
break
204+
}
205+
}
206+
if hasDots {
207+
path = Clean(path)
208+
}
209+
if path == ".." || strings.HasPrefix(path, "../") {
210+
return false
211+
}
212+
return true
213+
}
214+
175215
// ToSlash returns the result of replacing each separator character
176216
// in path with a slash ('/') character. Multiple separators are
177217
// replaced by multiple slashes.

src/path/filepath/path_plan9.go

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ package filepath
66

77
import "strings"
88

9+
func isLocal(path string) bool {
10+
return unixIsLocal(path)
11+
}
12+
913
// IsAbs reports whether the path is absolute.
1014
func IsAbs(path string) bool {
1115
return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "#")

src/path/filepath/path_test.go

+54
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,60 @@ func TestClean(t *testing.T) {
143143
}
144144
}
145145

146+
type IsLocalTest struct {
147+
path string
148+
isLocal bool
149+
}
150+
151+
var islocaltests = []IsLocalTest{
152+
{"", false},
153+
{".", true},
154+
{"..", false},
155+
{"../a", false},
156+
{"/", false},
157+
{"/a", false},
158+
{"/a/../..", false},
159+
{"a", true},
160+
{"a/../a", true},
161+
{"a/", true},
162+
{"a/.", true},
163+
{"a/./b/./c", true},
164+
}
165+
166+
var winislocaltests = []IsLocalTest{
167+
{"NUL", false},
168+
{"nul", false},
169+
{"nul.", false},
170+
{"nul.txt", false},
171+
{"com1", false},
172+
{"./nul", false},
173+
{"a/nul.txt/b", false},
174+
{`\`, false},
175+
{`\a`, false},
176+
{`C:`, false},
177+
{`C:\a`, false},
178+
{`..\a`, false},
179+
}
180+
181+
var plan9islocaltests = []IsLocalTest{
182+
{"#a", false},
183+
}
184+
185+
func TestIsLocal(t *testing.T) {
186+
tests := islocaltests
187+
if runtime.GOOS == "windows" {
188+
tests = append(tests, winislocaltests...)
189+
}
190+
if runtime.GOOS == "plan9" {
191+
tests = append(tests, plan9islocaltests...)
192+
}
193+
for _, test := range tests {
194+
if got := filepath.IsLocal(test.path); got != test.isLocal {
195+
t.Errorf("IsLocal(%q) = %v, want %v", test.path, got, test.isLocal)
196+
}
197+
}
198+
}
199+
146200
const sep = filepath.Separator
147201

148202
var slashtests = []PathTest{

src/path/filepath/path_unix.go

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ package filepath
88

99
import "strings"
1010

11+
func isLocal(path string) bool {
12+
return unixIsLocal(path)
13+
}
14+
1115
// IsAbs reports whether the path is absolute.
1216
func IsAbs(path string) bool {
1317
return strings.HasPrefix(path, "/")

src/path/filepath/path_windows.go

+67
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,73 @@ func toUpper(c byte) byte {
2020
return c
2121
}
2222

23+
// isReservedName reports if name is a Windows reserved device name.
24+
// It does not detect names with an extension, which are also reserved on some Windows versions.
25+
//
26+
// For details, search for PRN in
27+
// https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file.
28+
func isReservedName(name string) bool {
29+
if 3 <= len(name) && len(name) <= 4 {
30+
switch string([]byte{toUpper(name[0]), toUpper(name[1]), toUpper(name[2])}) {
31+
case "CON", "PRN", "AUX", "NUL":
32+
return len(name) == 3
33+
case "COM", "LPT":
34+
return len(name) == 4 && '1' <= name[3] && name[3] <= '9'
35+
}
36+
}
37+
return false
38+
}
39+
40+
func isLocal(path string) bool {
41+
if path == "" {
42+
return false
43+
}
44+
if isSlash(path[0]) {
45+
// Path rooted in the current drive.
46+
return false
47+
}
48+
if strings.IndexByte(path, ':') >= 0 {
49+
// Colons are only valid when marking a drive letter ("C:foo").
50+
// Rejecting any path with a colon is conservative but safe.
51+
return false
52+
}
53+
hasDots := false // contains . or .. path elements
54+
for p := path; p != ""; {
55+
var part string
56+
part, p, _ = cutPath(p)
57+
if part == "." || part == ".." {
58+
hasDots = true
59+
}
60+
// Trim the extension and look for a reserved name.
61+
base, _, hasExt := strings.Cut(part, ".")
62+
if isReservedName(base) {
63+
if !hasExt {
64+
return false
65+
}
66+
// The path element is a reserved name with an extension. Some Windows
67+
// versions consider this a reserved name, while others do not. Use
68+
// FullPath to see if the name is reserved.
69+
//
70+
// FullPath will convert references to reserved device names to their
71+
// canonical form: \\.\${DEVICE_NAME}
72+
//
73+
// FullPath does not perform this conversion for paths which contain
74+
// a reserved device name anywhere other than in the last element,
75+
// so check the part rather than the full path.
76+
if p, _ := syscall.FullPath(part); len(p) >= 4 && p[:4] == `\\.\` {
77+
return false
78+
}
79+
}
80+
}
81+
if hasDots {
82+
path = Clean(path)
83+
}
84+
if path == ".." || strings.HasPrefix(path, `..\`) {
85+
return false
86+
}
87+
return true
88+
}
89+
2390
// IsAbs reports whether the path is absolute.
2491
func IsAbs(path string) (b bool) {
2592
l := volumeNameLen(path)

0 commit comments

Comments
 (0)