Files
atlas/internal/validation/validator.go
othman.suseno df475bc85e
Some checks failed
CI / test-build (push) Failing after 2m11s
logging and diagnostic features added
2025-12-15 00:45:14 +07:00

279 lines
7.6 KiB
Go

package validation
import (
"fmt"
"regexp"
"strings"
"unicode"
)
var (
// Valid pool/dataset name pattern (ZFS naming rules)
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
}