181 lines
5.2 KiB
Go
181 lines
5.2 KiB
Go
package pathutil
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestValidateAccepts(t *testing.T) {
|
|
ok := []string{
|
|
"index.html",
|
|
"assets/app.js",
|
|
"a/b/c/d/e/f/g.txt",
|
|
".well-known/acme-challenge/token",
|
|
"_next/static/chunks/main-abc123.js",
|
|
"文档/说明.html",
|
|
"a file with spaces.html",
|
|
"weird!@#$%^&()+=[]{};'\",<>?.html",
|
|
"..hidden", // only an exact ".." component is a traversal
|
|
"a/..b/c", //
|
|
"...", // not "." and not ".."
|
|
"~tilde.html", // no special meaning below a project prefix
|
|
strings.Repeat("a", MaxSegmentBytes),
|
|
}
|
|
for _, p := range ok {
|
|
if err := Validate(p); err != nil {
|
|
t.Errorf("Validate(%q) = %v, want nil", p, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateRejects(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
want error
|
|
}{
|
|
{"", ErrEmpty},
|
|
{".", ErrNotRelative},
|
|
{"..", ErrNotRelative},
|
|
{"../etc/passwd", ErrNotRelative},
|
|
{"a/../../etc/passwd", ErrNotRelative},
|
|
{"a/./b", ErrNotRelative},
|
|
{"/etc/passwd", ErrNotRelative},
|
|
{"a//b", ErrNotRelative},
|
|
{"a/", ErrNotRelative},
|
|
{"/", ErrNotRelative},
|
|
{"a/b/..", ErrNotRelative},
|
|
{"a\x00b", ErrControlChar},
|
|
{"a\nb", ErrControlChar},
|
|
{"a\tb", ErrControlChar},
|
|
{"a\x1b[31m", ErrControlChar},
|
|
{"a\x7fb", ErrControlChar},
|
|
{"a\\b", ErrBackslash},
|
|
{"..\\..\\windows", ErrBackslash},
|
|
{"a/\xff\xfe/b", ErrNotUTF8},
|
|
{strings.Repeat("a", MaxSegmentBytes+1), ErrSegmentTooLong},
|
|
{"ok/" + strings.Repeat("b", MaxSegmentBytes+1), ErrSegmentTooLong},
|
|
{strings.Repeat("a/", MaxPathBytes/2) + "b", ErrTooLong},
|
|
}
|
|
for _, tc := range cases {
|
|
err := Validate(tc.path)
|
|
if !errors.Is(err, tc.want) {
|
|
t.Errorf("Validate(%q) = %v, want %v", tc.path, err, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The traversal cases are the ones that matter most, so state them again as an
|
|
// executable claim about what a manifest can never make Join produce.
|
|
func TestValidateBlocksEscape(t *testing.T) {
|
|
for _, p := range []string{
|
|
"../x", "a/../../x", "./../x", "/x", "a/b/../../../x",
|
|
"..", "a/..", "\\..\\x", "a\\..\\..\\x",
|
|
} {
|
|
if err := Validate(p); err == nil {
|
|
t.Errorf("Validate(%q) accepted a path that can escape its directory", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetDetectsCollisions(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
paths []string
|
|
want error
|
|
}{
|
|
{"duplicate", []string{"a.html", "a.html"}, ErrDuplicate},
|
|
{"case only", []string{"App.js", "app.js"}, ErrCaseCollision},
|
|
{"case in a directory", []string{"Assets/x.js", "assets/y.js"}, nil},
|
|
{"case collision under a folded directory", []string{"Assets/x.js", "assets/X.js"}, ErrCaseCollision},
|
|
{"file then directory", []string{"a", "a/b"}, ErrPathConflict},
|
|
{"directory then file", []string{"a/b", "a"}, ErrPathConflict},
|
|
{"deep file then directory", []string{"a/b/c", "a/b"}, ErrPathConflict},
|
|
{"file then deep directory", []string{"a/b", "a/b/c/d"}, ErrPathConflict},
|
|
{"case-folded file vs directory", []string{"A", "a/b"}, ErrPathConflict},
|
|
{"siblings are fine", []string{"a/b", "a/c", "a/d/e"}, nil},
|
|
{"unrelated", []string{"index.html", "assets/app.js", "assets/app.css"}, nil},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := NewSet(len(tc.paths))
|
|
var err error
|
|
for _, p := range tc.paths {
|
|
if err = s.Add(p); err != nil {
|
|
break
|
|
}
|
|
}
|
|
if tc.want == nil {
|
|
if err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
if s.Len() != len(tc.paths) {
|
|
t.Errorf("Len = %d, want %d", s.Len(), len(tc.paths))
|
|
}
|
|
return
|
|
}
|
|
if !errors.Is(err, tc.want) {
|
|
t.Fatalf("err = %v, want %v", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A rejected path must not leave a mark: the caller may report the error and
|
|
// carry on validating the rest of the manifest.
|
|
func TestSetRejectionLeavesNoTrace(t *testing.T) {
|
|
s := NewSet(4)
|
|
if err := s.Add("a/b"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Add("a"); !errors.Is(err, ErrPathConflict) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if err := s.Add("bad\x00path"); !errors.Is(err, ErrControlChar) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if s.Len() != 1 {
|
|
t.Errorf("Len = %d, want 1", s.Len())
|
|
}
|
|
if err := s.Add("a/c"); err != nil {
|
|
t.Errorf("a sibling must still be accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// The message has to name the other path, or a 50,000-file manifest reports a
|
|
// collision the operator cannot locate.
|
|
func TestCollisionErrorNamesTheOtherPath(t *testing.T) {
|
|
s := NewSet(2)
|
|
if err := s.Add("Assets/App.js"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := s.Add("assets/app.js")
|
|
if err == nil {
|
|
t.Fatal("want a collision")
|
|
}
|
|
if !strings.Contains(err.Error(), "Assets/App.js") {
|
|
t.Errorf("error %q does not name the conflicting path", err)
|
|
}
|
|
}
|
|
|
|
func FuzzValidate(f *testing.F) {
|
|
for _, s := range []string{"index.html", "a/b", "../x", "a\\b", "", ".", "a\x00b"} {
|
|
f.Add(s)
|
|
}
|
|
f.Fuzz(func(t *testing.T, p string) {
|
|
if Validate(p) != nil {
|
|
return
|
|
}
|
|
// Anything accepted must be safe to join onto a directory. Localize is
|
|
// the standard library's own statement of that property.
|
|
if strings.HasPrefix(p, "/") || strings.Contains(p, "\\") {
|
|
t.Fatalf("Validate accepted %q", p)
|
|
}
|
|
for _, seg := range strings.Split(p, "/") {
|
|
if seg == "" || seg == "." || seg == ".." {
|
|
t.Fatalf("Validate accepted %q with segment %q", p, seg)
|
|
}
|
|
}
|
|
})
|
|
}
|