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