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