// Package safepath validates package-controlled paths before they reach the // Windows filesystem. package safepath import ( "errors" "fmt" "os" "path" "path/filepath" "strings" "unicode" "unicode/utf8" ) var ( ErrInvalidRelativePath = errors.New("invalid Windows relative path") ErrOutsideRoot = errors.New("path is outside the managed root") ) // ValidateRelative accepts a canonical, slash-separated relative path that // names a file or directory below a managed root. func ValidateRelative(value string) error { return validate(value, false) } // ValidateWorkingDirectory accepts "." or a canonical relative directory. func ValidateWorkingDirectory(value string) error { return validate(value, true) } func validate(value string, allowCurrent bool) error { if value == "." && allowCurrent { return nil } if value == "" { return invalid("path is empty") } if !utf8.ValidString(value) { return invalid("path is not valid UTF-8") } if strings.HasPrefix(value, "/") || path.IsAbs(value) { return invalid("absolute paths are forbidden") } if strings.ContainsRune(value, '\\') { return invalid("backslashes are forbidden") } if cleaned := path.Clean(value); cleaned != value { return invalid("path is not canonical") } segments := strings.Split(value, "/") for _, segment := range segments { if err := validateSegment(segment); err != nil { return err } } return nil } func validateSegment(segment string) error { if segment == "" { return invalid("empty path segment") } if segment == "." || segment == ".." { return invalid("relative dot segment") } if strings.HasPrefix(segment, " ") || strings.HasSuffix(segment, " ") || strings.HasSuffix(segment, ".") { return invalid("path segment uses a Windows-trimmed character") } for _, character := range segment { if unicode.IsControl(character) || strings.ContainsRune(`<>:"\|?*`, character) { return invalid("path segment contains a Windows-forbidden character") } } if isReservedDeviceName(segment) { return invalid("path segment is a reserved Windows device name") } return nil } func isReservedDeviceName(segment string) bool { base := segment if dot := strings.IndexRune(base, '.'); dot >= 0 { base = base[:dot] } base = strings.TrimRight(base, " ") upper := strings.ToUpper(base) switch upper { case "CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$": return true } if len(upper) == 4 { prefix := upper[:3] digit := upper[3] if (prefix == "COM" || prefix == "LPT") && digit >= '1' && digit <= '9' { return true } } if len([]rune(upper)) == 4 { runes := []rune(upper) prefix := string(runes[:3]) digit := runes[3] if (prefix == "COM" || prefix == "LPT") && (digit == '\u00b9' || digit == '\u00b2' || digit == '\u00b3') { return true } } return false } // CollisionKey returns a stable Unicode simple-fold key for case-insensitive // duplicate detection. Callers should validate the path first. func CollisionKey(value string) string { var builder strings.Builder builder.Grow(len(value)) for _, character := range value { builder.WriteRune(foldRune(character)) } return builder.String() } func foldRune(character rune) rune { smallest := character for next := unicode.SimpleFold(character); next != character; next = unicode.SimpleFold(next) { if next < smallest { smallest = next } } return smallest } // JoinUnder joins a validated relative path below root and verifies the // resulting native path remains lexically contained by that root. func JoinUnder(root, relative string) (string, error) { if root == "" { return "", fmt.Errorf("%w: root is empty", ErrOutsideRoot) } if err := ValidateRelative(relative); err != nil { return "", err } absoluteRoot, err := filepath.Abs(root) if err != nil { return "", fmt.Errorf("%w: resolve root: %v", ErrOutsideRoot, err) } absoluteRoot = filepath.Clean(absoluteRoot) target := filepath.Clean(filepath.Join( absoluteRoot, filepath.FromSlash(relative), )) relativeTarget, err := filepath.Rel(absoluteRoot, target) if err != nil { return "", fmt.Errorf("%w: compare target: %v", ErrOutsideRoot, err) } if relativeTarget == ".." || filepath.IsAbs(relativeTarget) || strings.HasPrefix(relativeTarget, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("%w: %q", ErrOutsideRoot, relative) } return target, nil } func invalid(reason string) error { return fmt.Errorf("%w: %s", ErrInvalidRelativePath, reason) }