ZDDC/zddc/internal/fs/resolve.go
2026-06-11 13:32:31 -05:00

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