Skip to content

Commit e596e88

Browse files
committed
path/filepath: add Localize
Add the Localize function, which takes an io/fs slash-separated path and returns an operating system path. Localize returns an error if the path cannot be represented on the current platform. Replace internal/safefile.FromFS with Localize, which serves the same purpose as this function. The internal/safefile package remains separate from path/filepath to avoid a dependency cycle with the os package. Fixes #57151 Change-Id: I75c88047ddea17808276761da07bf79172c4f6fc Reviewed-on: https://go-review.googlesource.com/c/go/+/531677 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Ian Lance Taylor <iant@google.com>
1 parent 7b583fd commit e596e88

File tree

13 files changed

+149
-145
lines changed

13 files changed

+149
-145
lines changed

api/next/57151.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pkg path/filepath, func Localize(string) (string, error) #57151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
The new [`Localize`](/path/filepath#Localize) function safely converts
2+
a slash-separated path into an operating system path.

src/internal/safefilepath/path.go

+11-6
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@ package safefilepath
77

88
import (
99
"errors"
10+
"io/fs"
1011
)
1112

1213
var errInvalidPath = errors.New("invalid path")
1314

14-
// FromFS converts a slash-separated path into an operating-system path.
15+
// Localize is filepath.Localize.
1516
//
16-
// FromFS returns an error if the path cannot be represented by the operating
17-
// system. For example, paths containing '\' and ':' characters are rejected
18-
// on Windows.
19-
func FromFS(path string) (string, error) {
20-
return fromFS(path)
17+
// It is implemented in this package to avoid a dependency cycle
18+
// between os and file/filepath.
19+
//
20+
// Tests for this function are in path/filepath.
21+
func Localize(path string) (string, error) {
22+
if !fs.ValidPath(path) {
23+
return "", errInvalidPath
24+
}
25+
return localize(path)
2126
}

src/internal/safefilepath/path_other.go

-24
This file was deleted.
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright 2023 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package safefilepath
6+
7+
import "internal/bytealg"
8+
9+
func localize(path string) (string, error) {
10+
if path[0] == '#' || bytealg.IndexByteString(path, 0) >= 0 {
11+
return "", errInvalidPath
12+
}
13+
return path, nil
14+
}

src/internal/safefilepath/path_test.go

-88
This file was deleted.
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2023 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build unix || (js && wasm) || wasip1
6+
7+
package safefilepath
8+
9+
import "internal/bytealg"
10+
11+
func localize(path string) (string, error) {
12+
if bytealg.IndexByteString(path, 0) >= 0 {
13+
return "", errInvalidPath
14+
}
15+
return path, nil
16+
}

src/internal/safefilepath/path_windows.go

+15-20
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,31 @@
55
package safefilepath
66

77
import (
8+
"internal/bytealg"
89
"syscall"
9-
"unicode/utf8"
1010
)
1111

12-
func fromFS(path string) (string, error) {
13-
if !utf8.ValidString(path) {
14-
return "", errInvalidPath
15-
}
16-
for len(path) > 1 && path[0] == '/' && path[1] == '/' {
17-
path = path[1:]
12+
func localize(path string) (string, error) {
13+
for i := 0; i < len(path); i++ {
14+
switch path[i] {
15+
case ':', '\\', 0:
16+
return "", errInvalidPath
17+
}
1818
}
1919
containsSlash := false
2020
for p := path; p != ""; {
2121
// Find the next path element.
22-
i := 0
23-
for i < len(p) && p[i] != '/' {
24-
switch p[i] {
25-
case 0, '\\', ':':
26-
return "", errInvalidPath
27-
}
28-
i++
29-
}
30-
part := p[:i]
31-
if i < len(p) {
22+
var element string
23+
i := bytealg.IndexByteString(p, '/')
24+
if i < 0 {
25+
element = p
26+
p = ""
27+
} else {
3228
containsSlash = true
29+
element = p[:i]
3330
p = p[i+1:]
34-
} else {
35-
p = ""
3631
}
37-
if IsReservedName(part) {
32+
if IsReservedName(element) {
3833
return "", errInvalidPath
3934
}
4035
}

src/net/http/fs.go

+5-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ package http
99
import (
1010
"errors"
1111
"fmt"
12-
"internal/safefilepath"
1312
"io"
1413
"io/fs"
1514
"mime"
@@ -70,7 +69,11 @@ func mapOpenError(originalErr error, name string, sep rune, stat func(string) (f
7069
// Open implements [FileSystem] using [os.Open], opening files for reading rooted
7170
// and relative to the directory d.
7271
func (d Dir) Open(name string) (File, error) {
73-
path, err := safefilepath.FromFS(path.Clean("/" + name))
72+
path := path.Clean("/" + name)[1:]
73+
if path == "" {
74+
path = "."
75+
}
76+
path, err := filepath.Localize(path)
7477
if err != nil {
7578
return nil, errors.New("http: invalid or unsafe file path")
7679
}

src/os/dir.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func CopyFS(dir string, fsys fs.FS) error {
146146
return err
147147
}
148148

149-
fpath, err := safefilepath.FromFS(path)
149+
fpath, err := safefilepath.Localize(path)
150150
if err != nil {
151151
return err
152152
}

src/os/file.go

+1-4
Original file line numberDiff line numberDiff line change
@@ -747,10 +747,7 @@ func (dir dirFS) join(name string) (string, error) {
747747
if dir == "" {
748748
return "", errors.New("os: DirFS with empty root")
749749
}
750-
if !fs.ValidPath(name) {
751-
return "", ErrInvalid
752-
}
753-
name, err := safefilepath.FromFS(name)
750+
name, err := safefilepath.Localize(name)
754751
if err != nil {
755752
return "", ErrInvalid
756753
}

src/path/filepath/path.go

+16
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ package filepath
1313

1414
import (
1515
"errors"
16+
"internal/safefilepath"
1617
"io/fs"
1718
"os"
1819
"slices"
@@ -211,6 +212,18 @@ func unixIsLocal(path string) bool {
211212
return true
212213
}
213214

215+
// Localize converts a slash-separated path into an operating system path.
216+
// The input path must be a valid path as reported by [io/fs.ValidPath].
217+
//
218+
// Localize returns an error if the path cannot be represented by the operating system.
219+
// For example, the path a\b is rejected on Windows, on which \ is a separator
220+
// character and cannot be part of a filename.
221+
//
222+
// The path returned by Localize will always be local, as reported by IsLocal.
223+
func Localize(path string) (string, error) {
224+
return safefilepath.Localize(path)
225+
}
226+
214227
// ToSlash returns the result of replacing each separator character
215228
// in path with a slash ('/') character. Multiple separators are
216229
// replaced by multiple slashes.
@@ -224,6 +237,9 @@ func ToSlash(path string) string {
224237
// FromSlash returns the result of replacing each slash ('/') character
225238
// in path with a separator character. Multiple slashes are replaced
226239
// by multiple separators.
240+
//
241+
// See also the Localize function, which converts a slash-separated path
242+
// as used by the io/fs package to an operating system path.
227243
func FromSlash(path string) string {
228244
if Separator == '/' {
229245
return path

src/path/filepath/path_test.go

+67
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,73 @@ func TestIsLocal(t *testing.T) {
237237
}
238238
}
239239

240+
type LocalizeTest struct {
241+
path string
242+
want string
243+
}
244+
245+
var localizetests = []LocalizeTest{
246+
{"", ""},
247+
{".", "."},
248+
{"..", ""},
249+
{"a/..", ""},
250+
{"/", ""},
251+
{"/a", ""},
252+
{"a\xffb", ""},
253+
{"a/", ""},
254+
{"a/./b", ""},
255+
{"\x00", ""},
256+
{"a", "a"},
257+
{"a/b/c", "a/b/c"},
258+
}
259+
260+
var plan9localizetests = []LocalizeTest{
261+
{"#a", ""},
262+
{`a\b:c`, `a\b:c`},
263+
}
264+
265+
var unixlocalizetests = []LocalizeTest{
266+
{"#a", "#a"},
267+
{`a\b:c`, `a\b:c`},
268+
}
269+
270+
var winlocalizetests = []LocalizeTest{
271+
{"#a", "#a"},
272+
{"c:", ""},
273+
{`a\b`, ""},
274+
{`a:b`, ""},
275+
{`a/b:c`, ""},
276+
{`NUL`, ""},
277+
{`a/NUL`, ""},
278+
{`./com1`, ""},
279+
{`a/nul/b`, ""},
280+
}
281+
282+
func TestLocalize(t *testing.T) {
283+
tests := localizetests
284+
switch runtime.GOOS {
285+
case "plan9":
286+
tests = append(tests, plan9localizetests...)
287+
case "windows":
288+
tests = append(tests, winlocalizetests...)
289+
for i := range tests {
290+
tests[i].want = filepath.FromSlash(tests[i].want)
291+
}
292+
default:
293+
tests = append(tests, unixlocalizetests...)
294+
}
295+
for _, test := range tests {
296+
got, err := filepath.Localize(test.path)
297+
wantErr := "<nil>"
298+
if test.want == "" {
299+
wantErr = "error"
300+
}
301+
if got != test.want || ((err == nil) != (test.want != "")) {
302+
t.Errorf("IsLocal(%q) = %q, %v want %q, %v", test.path, got, err, test.want, wantErr)
303+
}
304+
}
305+
}
306+
240307
const sep = filepath.Separator
241308

242309
var slashtests = []PathTest{

0 commit comments

Comments
 (0)