a94637ab13
text extractions tests * increase timeout for slow ci/cd systems * add tests
320 lines
9.0 KiB
Go
320 lines
9.0 KiB
Go
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)
|
|
}
|