114 lines
3.4 KiB
Go
114 lines
3.4 KiB
Go
package fs
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ResolveCanonical translates a URL path to its canonical on-disk form
|
|
// under fsRoot, treating segment matches as case-insensitive. When a
|
|
// directory contains multiple case variants of the same name, the
|
|
// all-lowercase variant wins; lowercase is the project-wide canonical
|
|
// case for folders.
|
|
//
|
|
// The walk stops at the first segment that does not exist on disk:
|
|
// the remainder of the URL path is appended verbatim. This lets virtual
|
|
// prefixes (.profile, .archive, .tokens) and 404 paths flow through
|
|
// unchanged.
|
|
//
|
|
// Returns the resolved absolute path and the canonical URL form
|
|
// (leading "/", "/"-separated, no trailing "/"). ok is false only when
|
|
// the input would escape fsRoot.
|
|
//
|
|
// The fast path is one Lstat for the lowercase form of the segment;
|
|
// readdir+scan only runs when neither lowercase nor exact-case exists.
|
|
func ResolveCanonical(fsRoot, urlPath string) (absPath, canonicalURL string, ok bool) {
|
|
rel := strings.TrimPrefix(urlPath, "/")
|
|
rel = strings.TrimSuffix(rel, "/")
|
|
if rel == "" {
|
|
return fsRoot, "/", true
|
|
}
|
|
|
|
cur := fsRoot
|
|
canonParts := make([]string, 0, 8)
|
|
segments := strings.Split(rel, "/")
|
|
stopped := false
|
|
for _, seg := range segments {
|
|
if seg == "" || seg == "." || seg == ".." {
|
|
// Defensive — Clean strips these before this is called in
|
|
// practice, but if a caller passes a raw URL preserve it
|
|
// verbatim and let downstream safety checks reject it.
|
|
canonParts = append(canonParts, seg)
|
|
stopped = true
|
|
continue
|
|
}
|
|
if stopped {
|
|
canonParts = append(canonParts, seg)
|
|
continue
|
|
}
|
|
resolved, found := resolveSegment(cur, seg)
|
|
canonParts = append(canonParts, resolved)
|
|
if !found {
|
|
// Tail passes through verbatim — virtual prefixes
|
|
// (.archive, .tokens, .profile) and 404 paths land here.
|
|
stopped = true
|
|
continue
|
|
}
|
|
cur = filepath.Join(cur, resolved)
|
|
}
|
|
|
|
canonicalURL = "/" + strings.Join(canonParts, "/")
|
|
abs := filepath.Join(fsRoot, filepath.FromSlash(strings.Join(canonParts, "/")))
|
|
abs = filepath.Clean(abs)
|
|
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
|
|
return "", "", false
|
|
}
|
|
return abs, canonicalURL, true
|
|
}
|
|
|
|
// resolveSegment finds the on-disk name for a URL segment under parent.
|
|
// Order: (1) lowercase variant (canonical), (2) exact-case as given,
|
|
// (3) readdir scan picking the lowercase tiebreaker. Returns
|
|
// (segment-as-given, false) if nothing matches.
|
|
func resolveSegment(parent, segment string) (string, bool) {
|
|
lower := strings.ToLower(segment)
|
|
|
|
if _, err := os.Lstat(filepath.Join(parent, lower)); err == nil {
|
|
return lower, true
|
|
}
|
|
if segment != lower {
|
|
if _, err := os.Lstat(filepath.Join(parent, segment)); err == nil {
|
|
return segment, true
|
|
}
|
|
}
|
|
|
|
entries, err := os.ReadDir(parent)
|
|
if err != nil {
|
|
return segment, false
|
|
}
|
|
var matches []string
|
|
for _, e := range entries {
|
|
if strings.EqualFold(e.Name(), segment) {
|
|
matches = append(matches, e.Name())
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
return segment, false
|
|
case 1:
|
|
return matches[0], true
|
|
default:
|
|
// Multiple case variants exist on disk. Prefer the all-lowercase
|
|
// variant; otherwise pick the first in sort order so the choice
|
|
// is deterministic across requests.
|
|
for _, m := range matches {
|
|
if m == strings.ToLower(m) {
|
|
return m, true
|
|
}
|
|
}
|
|
sort.Strings(matches)
|
|
return matches[0], true
|
|
}
|
|
}
|