Files
pages/internal/pathutil/pathutil_test.go
T
2026-08-15 07:13:00 +00:00

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)
}
}
})
}