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

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