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