init
This commit is contained in:
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user