172 lines
6.4 KiB
Go
172 lines
6.4 KiB
Go
// 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) }
|