Skip to content

Commit

Permalink
path/filepath: add Localize
Browse files Browse the repository at this point in the history
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 <[email protected]>
Reviewed-by: Ian Lance Taylor <[email protected]>
  • Loading branch information
neild committed Feb 26, 2024
1 parent 7b583fd commit e596e88
Show file tree
Hide file tree
Showing 13 changed files with 149 additions and 145 deletions.
1 change: 1 addition & 0 deletions api/next/57151.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pkg path/filepath, func Localize(string) (string, error) #57151
2 changes: 2 additions & 0 deletions doc/next/6-stdlib/99-minor/path/filepath/57151.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The new [`Localize`](/path/filepath#Localize) function safely converts
a slash-separated path into an operating system path.
17 changes: 11 additions & 6 deletions src/internal/safefilepath/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ package safefilepath

import (
"errors"
"io/fs"
)

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

// FromFS converts a slash-separated path into an operating-system path.
// Localize is filepath.Localize.
//
// FromFS returns an error if the path cannot be represented by the operating
// system. For example, paths containing '\' and ':' characters are rejected
// on Windows.
func FromFS(path string) (string, error) {
return fromFS(path)
// It is implemented in this package to avoid a dependency cycle
// between os and file/filepath.
//
// Tests for this function are in path/filepath.
func Localize(path string) (string, error) {
if !fs.ValidPath(path) {
return "", errInvalidPath
}
return localize(path)
}
24 changes: 0 additions & 24 deletions src/internal/safefilepath/path_other.go

This file was deleted.

14 changes: 14 additions & 0 deletions src/internal/safefilepath/path_plan9.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package safefilepath

import "internal/bytealg"

func localize(path string) (string, error) {
if path[0] == '#' || bytealg.IndexByteString(path, 0) >= 0 {
return "", errInvalidPath
}
return path, nil
}
88 changes: 0 additions & 88 deletions src/internal/safefilepath/path_test.go

This file was deleted.

16 changes: 16 additions & 0 deletions src/internal/safefilepath/path_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build unix || (js && wasm) || wasip1

package safefilepath

import "internal/bytealg"

func localize(path string) (string, error) {
if bytealg.IndexByteString(path, 0) >= 0 {
return "", errInvalidPath
}
return path, nil
}
35 changes: 15 additions & 20 deletions src/internal/safefilepath/path_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,31 @@
package safefilepath

import (
"internal/bytealg"
"syscall"
"unicode/utf8"
)

func fromFS(path string) (string, error) {
if !utf8.ValidString(path) {
return "", errInvalidPath
}
for len(path) > 1 && path[0] == '/' && path[1] == '/' {
path = path[1:]
func localize(path string) (string, error) {
for i := 0; i < len(path); i++ {
switch path[i] {
case ':', '\\', 0:
return "", errInvalidPath
}
}
containsSlash := false
for p := path; p != ""; {
// Find the next path element.
i := 0
for i < len(p) && p[i] != '/' {
switch p[i] {
case 0, '\\', ':':
return "", errInvalidPath
}
i++
}
part := p[:i]
if i < len(p) {
var element string
i := bytealg.IndexByteString(p, '/')
if i < 0 {
element = p
p = ""
} else {
containsSlash = true
element = p[:i]
p = p[i+1:]
} else {
p = ""
}
if IsReservedName(part) {
if IsReservedName(element) {
return "", errInvalidPath
}
}
Expand Down
7 changes: 5 additions & 2 deletions src/net/http/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ package http
import (
"errors"
"fmt"
"internal/safefilepath"
"io"
"io/fs"
"mime"
Expand Down Expand Up @@ -70,7 +69,11 @@ func mapOpenError(originalErr error, name string, sep rune, stat func(string) (f
// Open implements [FileSystem] using [os.Open], opening files for reading rooted
// and relative to the directory d.
func (d Dir) Open(name string) (File, error) {
path, err := safefilepath.FromFS(path.Clean("/" + name))
path := path.Clean("/" + name)[1:]
if path == "" {
path = "."
}
path, err := filepath.Localize(path)
if err != nil {
return nil, errors.New("http: invalid or unsafe file path")
}
Expand Down
2 changes: 1 addition & 1 deletion src/os/dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func CopyFS(dir string, fsys fs.FS) error {
return err
}

fpath, err := safefilepath.FromFS(path)
fpath, err := safefilepath.Localize(path)
if err != nil {
return err
}
Expand Down
5 changes: 1 addition & 4 deletions src/os/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -747,10 +747,7 @@ func (dir dirFS) join(name string) (string, error) {
if dir == "" {
return "", errors.New("os: DirFS with empty root")
}
if !fs.ValidPath(name) {
return "", ErrInvalid
}
name, err := safefilepath.FromFS(name)
name, err := safefilepath.Localize(name)
if err != nil {
return "", ErrInvalid
}
Expand Down
16 changes: 16 additions & 0 deletions src/path/filepath/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package filepath

import (
"errors"
"internal/safefilepath"
"io/fs"
"os"
"slices"
Expand Down Expand Up @@ -211,6 +212,18 @@ func unixIsLocal(path string) bool {
return true
}

// Localize converts a slash-separated path into an operating system path.
// The input path must be a valid path as reported by [io/fs.ValidPath].
//
// Localize returns an error if the path cannot be represented by the operating system.
// For example, the path a\b is rejected on Windows, on which \ is a separator
// character and cannot be part of a filename.
//
// The path returned by Localize will always be local, as reported by IsLocal.
func Localize(path string) (string, error) {
return safefilepath.Localize(path)
}

// ToSlash returns the result of replacing each separator character
// in path with a slash ('/') character. Multiple separators are
// replaced by multiple slashes.
Expand All @@ -224,6 +237,9 @@ func ToSlash(path string) string {
// FromSlash returns the result of replacing each slash ('/') character
// in path with a separator character. Multiple slashes are replaced
// by multiple separators.
//
// See also the Localize function, which converts a slash-separated path
// as used by the io/fs package to an operating system path.
func FromSlash(path string) string {
if Separator == '/' {
return path
Expand Down
67 changes: 67 additions & 0 deletions src/path/filepath/path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,73 @@ func TestIsLocal(t *testing.T) {
}
}

type LocalizeTest struct {
path string
want string
}

var localizetests = []LocalizeTest{
{"", ""},
{".", "."},
{"..", ""},
{"a/..", ""},
{"/", ""},
{"/a", ""},
{"a\xffb", ""},
{"a/", ""},
{"a/./b", ""},
{"\x00", ""},
{"a", "a"},
{"a/b/c", "a/b/c"},
}

var plan9localizetests = []LocalizeTest{
{"#a", ""},
{`a\b:c`, `a\b:c`},
}

var unixlocalizetests = []LocalizeTest{
{"#a", "#a"},
{`a\b:c`, `a\b:c`},
}

var winlocalizetests = []LocalizeTest{
{"#a", "#a"},
{"c:", ""},
{`a\b`, ""},
{`a:b`, ""},
{`a/b:c`, ""},
{`NUL`, ""},
{`a/NUL`, ""},
{`./com1`, ""},
{`a/nul/b`, ""},
}

func TestLocalize(t *testing.T) {
tests := localizetests
switch runtime.GOOS {
case "plan9":
tests = append(tests, plan9localizetests...)
case "windows":
tests = append(tests, winlocalizetests...)
for i := range tests {
tests[i].want = filepath.FromSlash(tests[i].want)
}
default:
tests = append(tests, unixlocalizetests...)
}
for _, test := range tests {
got, err := filepath.Localize(test.path)
wantErr := "<nil>"
if test.want == "" {
wantErr = "error"
}
if got != test.want || ((err == nil) != (test.want != "")) {
t.Errorf("IsLocal(%q) = %q, %v want %q, %v", test.path, got, err, test.want, wantErr)
}
}
}

const sep = filepath.Separator

var slashtests = []PathTest{
Expand Down

0 comments on commit e596e88

Please sign in to comment.