This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
// Package pathutil validates the site-relative paths a deployment manifest may
// declare.
//
// It depends on the standard library only. Both sides run these checks: the CLI
// so a bad path is caught before anything is uploaded, and the server because a
// client is never a trust boundary. cmd/pages/deps_test.go fails the build if
// anything server-side reaches the CLI through this package.
package pathutil
import (
"errors"
"fmt"
"io/fs"
"path"
"path/filepath"
"strings"
"unicode/utf8"
)
const (
// MaxPathBytes caps a whole path. Linux's PATH_MAX is 4096 including the
// deployment directory this gets joined onto, so anything approaching it is
// already not a real build artifact.
MaxPathBytes = 4096
// MaxSegmentBytes caps one component, matching the NAME_MAX of every
// filesystem a deployment tree is plausibly assembled on.
MaxSegmentBytes = 255
)
// Why a path can be refused. Callers match on these with errors.Is; the message
// carries the specifics.
var (
ErrEmpty = errors.New("path is empty")
ErrTooLong = errors.New("path is too long")
ErrSegmentTooLong = errors.New("path component is too long")
ErrNotUTF8 = errors.New("path is not valid UTF-8")
ErrControlChar = errors.New("path contains a control character")
ErrBackslash = errors.New("path contains a backslash")
ErrNotRelative = errors.New("path must be relative and slash-separated, with no empty, \".\" or \"..\" component")
ErrNotLocal = errors.New("path is not usable as a local filename")
ErrDuplicate = errors.New("path appears twice in the manifest")
ErrCaseCollision = errors.New("path differs from another only by letter case")
ErrPathConflict = errors.New("path is used as both a file and a directory")
)
// Validate reports whether p may appear in a manifest.
//
// The checks overlap on purpose. fs.ValidPath states the semantic rule —
// relative, slash-separated, no empty or "." or ".." component, no trailing
// slash — and filepath.Localize answers the question that actually matters when
// the tree is assembled: can this become a filename that filepath.Join is
// unable to walk out of the destination directory. On Windows Localize also
// rejects the reserved device names. Running both means that a future change to
// either one cannot quietly widen what is accepted.
func Validate(p string) error {
if p == "" {
return ErrEmpty
}
if len(p) > MaxPathBytes {
return fmt.Errorf("%w: %d bytes, limit is %d", ErrTooLong, len(p), MaxPathBytes)
}
if !utf8.ValidString(p) {
// These bytes would be a perfectly legal filename on Linux, but the path
// is also a value in a TEXT column, and SQLite's comparison and
// collation behaviour on non-UTF-8 is undefined. The database is the
// stricter of the two, so it sets the rule.
return ErrNotUTF8
}
for i := 0; i < len(p); i++ {
// A newline would forge a second line in the access log; an ESC would
// let a manifest repaint the operator's terminal during `pages
// deployment show --files`. NUL would truncate the path at the syscall
// boundary while the database kept the whole thing.
if c := p[i]; c < 0x20 || c == 0x7f {
return ErrControlChar
}
}
if strings.ContainsRune(p, '\\') {
// An ordinary filename character on Linux and a separator on Windows.
// Refusing it outright is what makes a manifest mean the same thing
// wherever the tree is assembled.
return ErrBackslash
}
if !fs.ValidPath(p) || p == "." {
return ErrNotRelative
}
for _, seg := range strings.Split(p, "/") {
if len(seg) > MaxSegmentBytes {
return fmt.Errorf("%w: %d bytes, limit is %d", ErrSegmentTooLong, len(seg), MaxSegmentBytes)
}
}
if _, err := filepath.Localize(p); err != nil {
return fmt.Errorf("%w: %v", ErrNotLocal, err)
}
return nil
}
// Set accumulates one manifest's paths and rejects the pairs that cannot
// coexist in a single directory tree.
//
// Three kinds, each of which would otherwise surface far from its cause:
//
// - An exact duplicate declares two digests for one path. Assembly would
// write whichever arrived last and the site would be subtly wrong.
// - A case-only collision ("App.js" and "app.js") assembles fine on ext4 and
// silently loses a file on APFS or NTFS — a failure that reproduces on the
// server and on nobody's laptop.
// - A file that another entry needs as a directory ("a" and "a/b") fails
// mid-assembly with a bare ENOTDIR, after part of the tree already exists.
//
// Catching all three at manifest time turns each into one clear error naming
// both paths, before a single byte is uploaded.
type Set struct {
files map[string]string // folded path -> the path that claimed it
dirs map[string]string // folded directory prefix -> a path that needs it
}
// NewSet returns a Set sized for an expected number of files.
func NewSet(size int) *Set {
return &Set{
files: make(map[string]string, size),
dirs: make(map[string]string, size/4+1),
}
}
// Add validates p and records it. The error names the path it conflicts with,
// which is the only detail that makes a collision fixable.
func (s *Set) Add(p string) error {
if err := Validate(p); err != nil {
return err
}
folded := fold(p)
if prev, ok := s.files[folded]; ok {
if prev == p {
return ErrDuplicate
}
return fmt.Errorf("%w: %q", ErrCaseCollision, prev)
}
if prev, ok := s.dirs[folded]; ok {
return fmt.Errorf("%w: %q needs it as a directory", ErrPathConflict, prev)
}
// Check every ancestor before recording anything, so a rejected path leaves
// the set exactly as it was.
for dir := path.Dir(p); dir != "."; dir = path.Dir(dir) {
if prev, ok := s.files[fold(dir)]; ok {
return fmt.Errorf("%w: %q is a file", ErrPathConflict, prev)
}
}
s.files[folded] = p
for dir := path.Dir(p); dir != "."; dir = path.Dir(dir) {
fd := fold(dir)
if _, ok := s.dirs[fd]; ok {
// Every shallower ancestor is already recorded too.
break
}
s.dirs[fd] = p
}
return nil
}
// Len is how many paths have been accepted.
func (s *Set) Len() int { return len(s.files) }
// fold is the key under which two names collide on a case-insensitive
// filesystem. strings.ToLower is not byte-for-byte what APFS or NTFS do — they
// each pin a particular Unicode version's table — but it agrees with both on
// everything a build tool emits, and disagreeing by being too strict is the
// safe direction.
func fold(p string) string { return strings.ToLower(p) }
+180
View File
@@ -0,0 +1,180 @@
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)
}
}
})
}