package validation import ( "fmt" "regexp" "strings" "unicode" ) var ( // Valid pool/dataset name pattern (ZFS naming rules) // Note: Forward slash (/) is allowed for dataset paths (e.g., "tank/data") zfsNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.:/]*$`) // Valid username pattern usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{2,31}$`) // Valid share name pattern (SMB naming rules) shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,79}$`) // IQN pattern (simplified - iqn.yyyy-mm.reversed.domain:identifier) iqnPattern = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*:[a-zA-Z0-9][a-zA-Z0-9\-_\.]*$`) // Email pattern (basic) emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) // CIDR pattern for NFS clients cidrPattern = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}(/\d{1,2})?$`) ) // ValidationError represents a validation error type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { if e.Field != "" { return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message) } return fmt.Sprintf("validation error: %s", e.Message) } // ValidateZFSName validates a ZFS pool or dataset name func ValidateZFSName(name string) error { if name == "" { return &ValidationError{Field: "name", Message: "name cannot be empty"} } if len(name) > 256 { return &ValidationError{Field: "name", Message: "name too long (max 256 characters)"} } if !zfsNamePattern.MatchString(name) { return &ValidationError{Field: "name", Message: "invalid characters (allowed: a-z, A-Z, 0-9, _, -, ., :, /)"} } // ZFS names cannot start with certain characters if strings.HasPrefix(name, "-") || strings.HasPrefix(name, ".") { return &ValidationError{Field: "name", Message: "name cannot start with '-' or '.'"} } return nil } // ValidateUsername validates a username func ValidateUsername(username string) error { if username == "" { return &ValidationError{Field: "username", Message: "username cannot be empty"} } if len(username) < 3 { return &ValidationError{Field: "username", Message: "username too short (min 3 characters)"} } if len(username) > 32 { return &ValidationError{Field: "username", Message: "username too long (max 32 characters)"} } if !usernamePattern.MatchString(username) { return &ValidationError{Field: "username", Message: "invalid characters (allowed: a-z, A-Z, 0-9, _, -, .)"} } return nil } // ValidatePassword validates a password func ValidatePassword(password string) error { if password == "" { return &ValidationError{Field: "password", Message: "password cannot be empty"} } if len(password) < 8 { return &ValidationError{Field: "password", Message: "password too short (min 8 characters)"} } if len(password) > 128 { return &ValidationError{Field: "password", Message: "password too long (max 128 characters)"} } // Check for at least one letter and one number hasLetter := false hasNumber := false for _, r := range password { if unicode.IsLetter(r) { hasLetter = true } if unicode.IsNumber(r) { hasNumber = true } } if !hasLetter { return &ValidationError{Field: "password", Message: "password must contain at least one letter"} } if !hasNumber { return &ValidationError{Field: "password", Message: "password must contain at least one number"} } return nil } // ValidateEmail validates an email address func ValidateEmail(email string) error { if email == "" { return nil // Email is optional } if len(email) > 254 { return &ValidationError{Field: "email", Message: "email too long (max 254 characters)"} } if !emailPattern.MatchString(email) { return &ValidationError{Field: "email", Message: "invalid email format"} } return nil } // ValidateShareName validates an SMB share name func ValidateShareName(name string) error { if name == "" { return &ValidationError{Field: "name", Message: "share name cannot be empty"} } if len(name) > 80 { return &ValidationError{Field: "name", Message: "share name too long (max 80 characters)"} } if !shareNamePattern.MatchString(name) { return &ValidationError{Field: "name", Message: "invalid share name (allowed: a-z, A-Z, 0-9, _, -, .)"} } // Reserved names reserved := []string{"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"} upperName := strings.ToUpper(name) for _, r := range reserved { if upperName == r { return &ValidationError{Field: "name", Message: fmt.Sprintf("share name '%s' is reserved", name)} } } return nil } // ValidateIQN validates an iSCSI Qualified Name func ValidateIQN(iqn string) error { if iqn == "" { return &ValidationError{Field: "iqn", Message: "IQN cannot be empty"} } if len(iqn) > 223 { return &ValidationError{Field: "iqn", Message: "IQN too long (max 223 characters)"} } if !strings.HasPrefix(iqn, "iqn.") { return &ValidationError{Field: "iqn", Message: "IQN must start with 'iqn.'"} } // Basic format validation (can be more strict) if !iqnPattern.MatchString(iqn) { return &ValidationError{Field: "iqn", Message: "invalid IQN format (expected: iqn.yyyy-mm.reversed.domain:identifier)"} } return nil } // ValidateSize validates a size string (e.g., "10G", "1T") func ValidateSize(sizeStr string) error { if sizeStr == "" { return &ValidationError{Field: "size", Message: "size cannot be empty"} } // Pattern: number followed by optional unit (K, M, G, T, P) sizePattern := regexp.MustCompile(`^(\d+)([KMGT]?)$`) if !sizePattern.MatchString(strings.ToUpper(sizeStr)) { return &ValidationError{Field: "size", Message: "invalid size format (expected: number with optional unit K, M, G, T, P)"} } return nil } // ValidatePath validates a filesystem path func ValidatePath(path string) error { if path == "" { return nil // Path is optional (can be auto-filled) } if !strings.HasPrefix(path, "/") { return &ValidationError{Field: "path", Message: "path must be absolute (start with /)"} } if len(path) > 4096 { return &ValidationError{Field: "path", Message: "path too long (max 4096 characters)"} } // Check for dangerous path components dangerous := []string{"..", "//", "\x00"} for _, d := range dangerous { if strings.Contains(path, d) { return &ValidationError{Field: "path", Message: fmt.Sprintf("path contains invalid component: %s", d)} } } return nil } // ValidateCIDR validates a CIDR notation or hostname func ValidateCIDR(cidr string) error { if cidr == "" { return &ValidationError{Field: "client", Message: "client cannot be empty"} } // Allow wildcard if cidr == "*" { return nil } // Check if it's a CIDR if cidrPattern.MatchString(cidr) { return nil } // Check if it's a valid hostname hostnamePattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) if hostnamePattern.MatchString(cidr) { return nil } return &ValidationError{Field: "client", Message: "invalid client format (expected: CIDR, hostname, or '*')"} } // SanitizeString removes potentially dangerous characters func SanitizeString(s string) string { // Remove null bytes and control characters var result strings.Builder for _, r := range s { if r >= 32 && r != 127 { result.WriteRune(r) } } return strings.TrimSpace(result.String()) } // SanitizePath sanitizes a filesystem path func SanitizePath(path string) string { // Remove leading/trailing whitespace and normalize slashes path = strings.TrimSpace(path) path = strings.ReplaceAll(path, "\\", "/") // Remove multiple slashes for strings.Contains(path, "//") { path = strings.ReplaceAll(path, "//", "/") } return path }