package folder import ( "errors" "fmt" "regexp" "strings" "unicode" "golang.org/x/text/unicode/norm" ) // PathValidationError represents a folder path constraint violation. // It wraps the underlying constraint error with context about which constraint was violated. type PathValidationError struct { Constraint string Message string } func (e *PathValidationError) Error() string { return e.Message } // IsPathValidationError checks if the error is a PathValidationError func IsPathValidationError(err error) bool { var pve *PathValidationError return errors.As(err, &pve) } // Constants for validation limits const ( MaxSegmentLength = 255 MaxPathDepth = 20 MaxPathLength = 1000 ) // validPathCharRegex matches valid characters: alphanumeric, hyphen, underscore, period, space // This is applied per-segment after splitting by / var validSegmentCharRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\- ]*$`) // windowsReservedNames are device names reserved on Windows (case-insensitive) var windowsReservedNames = map[string]bool{ "con": true, "prn": true, "aux": true, "nul": true, "com1": true, "com2": true, "com3": true, "com4": true, "com5": true, "com6": true, "com7": true, "com8": true, "com9": true, "lpt1": true, "lpt2": true, "lpt3": true, "lpt4": true, "lpt5": true, "lpt6": true, "lpt7": true, "lpt8": true, "lpt9": true, } // ValidateFolderPath validates a folder path against all constraints. // This function should be called for both folder creation and rename operations. // // Constraints validated: // - Path must start with / // - Path cannot exceed 1000 characters // - No empty segments (double slashes) // - No path traversal sequences (.., ./) // - No control characters // - Valid characters only (alphanumeric, hyphen, underscore, period, space) // - No leading/trailing whitespace in segments // - No dot-prefix segments (hidden files) // - No Windows reserved names // - Segment length <= 255 characters // - Path depth <= 20 levels // - Unicode NFC normalization applied // // Parameters: // - path: the folder path to validate // // Returns: // - error: nil if valid, PathValidationError if constraint violated func ValidateFolderPath(path string) error { // Normalize Unicode to NFC form path = norm.NFC.String(path) // Check for control characters first (before any other validation) if err := validateNoControlCharacters(path); err != nil { return err } // Must start with / if !strings.HasPrefix(path, "/") { return &PathValidationError{ Constraint: "start_with_slash", Message: "folder path must start with /", } } // Check max path length if len(path) > MaxPathLength { return &PathValidationError{ Constraint: "max_length", Message: fmt.Sprintf("folder path exceeds maximum length of %d characters", MaxPathLength), } } // Special case: root path "/" is always valid if path == "/" { return nil } // Check for path traversal sequences if err := validateNoPathTraversal(path); err != nil { return err } // Check for empty segments (double slashes or trailing slash) if err := validateNoEmptySegments(path); err != nil { return err } // Split into segments and validate each segments := strings.Split(path[1:], "/") // Remove leading / before split // Check path depth if len(segments) > MaxPathDepth { return &PathValidationError{ Constraint: "max_depth", Message: fmt.Sprintf("folder path exceeds maximum depth of %d levels", MaxPathDepth), } } for _, segment := range segments { if err := validateSegment(segment); err != nil { return err } } return nil } // validateNoControlCharacters checks that the path contains no ASCII control characters (0x00-0x1F, 0x7F) func validateNoControlCharacters(path string) error { for _, r := range path { if r < 0x20 || r == 0x7F { return &PathValidationError{ Constraint: "control_characters", Message: "folder path cannot contain control characters", } } } return nil } // validateNoPathTraversal checks for .. and ./ sequences func validateNoPathTraversal(path string) error { // Check for .. anywhere in path if strings.Contains(path, "..") { return &PathValidationError{ Constraint: "path_traversal", Message: "folder path cannot contain '..' sequences", } } // Check for ./ sequence if strings.Contains(path, "./") { return &PathValidationError{ Constraint: "path_traversal", Message: "folder path cannot contain './' sequences", } } // Check for /. at end of path if strings.HasSuffix(path, "/.") { return &PathValidationError{ Constraint: "path_traversal", Message: "folder path cannot end with '/.'", } } return nil } // validateNoEmptySegments checks for empty path segments func validateNoEmptySegments(path string) error { // Check for double slashes if strings.Contains(path, "//") { return &PathValidationError{ Constraint: "empty_segment", Message: "folder path cannot contain empty segments (double slashes)", } } // Check for trailing slash (creates empty segment) if strings.HasSuffix(path, "/") { return &PathValidationError{ Constraint: "empty_segment", Message: "folder path cannot end with a trailing slash", } } return nil } // validateSegment validates a single path segment func validateSegment(segment string) error { // Check segment length if len(segment) > MaxSegmentLength { return &PathValidationError{ Constraint: "segment_length", Message: fmt.Sprintf("folder path segment '%s...' exceeds maximum length of %d characters", segment[:50], MaxSegmentLength), } } // Check for empty segment (shouldn't happen if validateNoEmptySegments passed, but defensive) if segment == "" { return &PathValidationError{ Constraint: "empty_segment", Message: "folder path cannot contain empty segments", } } // Check for leading/trailing whitespace if segment != strings.TrimSpace(segment) { return &PathValidationError{ Constraint: "whitespace", Message: "folder path segments cannot have leading or trailing spaces", } } // Check for dot prefix (hidden files) if strings.HasPrefix(segment, ".") { return &PathValidationError{ Constraint: "hidden_folder", Message: "folder path segments cannot start with a period", } } // Check for valid characters if !validSegmentCharRegex.MatchString(segment) { // Provide more helpful error message by identifying invalid character invalidChar := findInvalidCharacter(segment) return &PathValidationError{ Constraint: "invalid_characters", Message: fmt.Sprintf("folder path contains invalid character '%s'; only alphanumeric, hyphen, underscore, period, and space are allowed", invalidChar), } } // Check for Windows reserved names segmentLower := strings.ToLower(segment) if windowsReservedNames[segmentLower] { return &PathValidationError{ Constraint: "reserved_name", Message: fmt.Sprintf("folder path cannot use reserved name: %s", segment), } } return nil } // findInvalidCharacter returns the first invalid character in a segment for error reporting func findInvalidCharacter(segment string) string { for i, r := range segment { // First character must be alphanumeric if i == 0 { if !unicode.IsLetter(r) && !unicode.IsDigit(r) { return string(r) } continue } // Subsequent characters can be alphanumeric, hyphen, underscore, period, or space if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' && r != '.' && r != ' ' { return string(r) } } return "?" } // ValidateFolderPathForRename validates a folder path for rename operations. // This includes all standard path validation plus additional rename-specific checks. // // Parameters: // - newPath: the new folder path // - isRootFolder: true if the folder being renamed is the root folder // // Returns: // - error: nil if valid, PathValidationError if constraint violated func ValidateFolderPathForRename(newPath string, isRootFolder bool) error { // Root folder cannot be renamed if isRootFolder { return &PathValidationError{ Constraint: "root_rename", Message: "root folder cannot be renamed", } } // Cannot rename to root if newPath == "/" { return &PathValidationError{ Constraint: "rename_to_root", Message: "cannot rename folder to root path", } } // Apply standard path validation return ValidateFolderPath(newPath) } // ValidateFolderPathForCreate validates a folder path for create operations. // This includes all standard path validation plus creation-specific checks. // // Parameters: // - path: the folder path to create // - rootExists: true if a root folder already exists for the client // // Returns: // - error: nil if valid, PathValidationError if constraint violated func ValidateFolderPathForCreate(path string, rootExists bool) error { // Check if trying to create root when one already exists if path == "/" && rootExists { return &PathValidationError{ Constraint: "root_exists", Message: "root folder already exists for this client", } } // Apply standard path validation return ValidateFolderPath(path) }