Files
lazyssh/internal/adapters/ui/validation.go
T

714 lines
19 KiB
Go

// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
)
// fieldValidator contains validation rules for SSH configuration fields
type fieldValidator struct {
Required bool
Pattern *regexp.Regexp
Validate func(string) error
Message string
}
// ValidationState tracks validation errors for each field
type ValidationState struct {
errors map[string]string
mu sync.RWMutex
}
// NewValidationState creates a new validation state
func NewValidationState() *ValidationState {
return &ValidationState{
errors: make(map[string]string),
}
}
// SetError sets or clears an error for a field
func (v *ValidationState) SetError(field, errMsg string) {
v.mu.Lock()
defer v.mu.Unlock()
if errMsg == "" {
delete(v.errors, field)
} else {
v.errors[field] = errMsg
}
}
// GetError gets the error for a specific field
func (v *ValidationState) GetError(field string) string {
v.mu.RLock()
defer v.mu.RUnlock()
return v.errors[field]
}
// HasErrors checks if there are any validation errors
func (v *ValidationState) HasErrors() bool {
v.mu.RLock()
defer v.mu.RUnlock()
return len(v.errors) > 0
}
// GetErrorCount returns the number of validation errors
func (v *ValidationState) GetErrorCount() int {
v.mu.RLock()
defer v.mu.RUnlock()
return len(v.errors)
}
// GetAllErrors returns all validation errors in field order
func (v *ValidationState) GetAllErrors() []string {
v.mu.RLock()
defer v.mu.RUnlock()
// Define field order for consistent error display
fieldOrder := []string{
"Alias", "Host", "Port", "User", "Keys", "Tags",
"ConnectTimeout", "ConnectionAttempts", "ServerAliveInterval", "ServerAliveCountMax",
"IPQoS", "BindAddress", "LocalForward", "RemoteForward", "DynamicForward",
"NumberOfPasswordPrompts", "CanonicalizeMaxDots", "EscapeChar",
}
// Create a set for O(1) lookups
fieldOrderSet := make(map[string]bool, len(fieldOrder))
for _, field := range fieldOrder {
fieldOrderSet[field] = true
}
errors := make([]string, 0, len(v.errors))
// Add errors in defined order
for _, field := range fieldOrder {
if err, exists := v.errors[field]; exists {
errors = append(errors, fmt.Sprintf("%s: %s", field, err))
}
}
// Add any other errors not in the defined order
for field, err := range v.errors {
if !fieldOrderSet[field] {
errors = append(errors, fmt.Sprintf("%s: %s", field, err))
}
}
return errors
}
// Clear removes all validation errors
func (v *ValidationState) Clear() {
v.mu.Lock()
defer v.mu.Unlock()
v.errors = make(map[string]string)
}
// invalidHostChars contains characters that are not allowed in hostnames
const invalidHostChars = "@#$%^&*()=+[]{}|\\;:'\"<>,?/"
// invalidAddressChars contains characters that are not allowed in bind addresses
const invalidAddressChars = "@#$%^&()=+{}|\\;:'\"<>,?/"
// GetFieldValidators returns validation rules for SSH configuration fields
func GetFieldValidators() map[string]fieldValidator {
validators := make(map[string]fieldValidator)
// Basic fields
validators["Alias"] = fieldValidator{
Required: true,
Pattern: regexp.MustCompile(`^[a-zA-Z0-9._-]+$`),
Message: "Alias is required and can only contain letters, numbers, dots, hyphens, and underscores",
}
validators["Host"] = fieldValidator{
Required: true,
Validate: validateHost,
Message: "Host is required and must be a valid hostname or IP address",
}
validators["Port"] = fieldValidator{
Pattern: regexp.MustCompile(`^([1-9]\d{0,4})$`),
Validate: validatePort,
Message: "Port must be between 1 and 65535",
}
validators["User"] = fieldValidator{
Pattern: regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9._-]*$`),
Message: "User must start with a letter and contain only letters, numbers, dots, hyphens, and underscores",
}
validators["Keys"] = fieldValidator{
Validate: validateKeyPaths,
Message: "Key file not found or not accessible",
}
// Connection fields
validators["ConnectTimeout"] = fieldValidator{
Validate: validateConnectTimeout,
Message: "ConnectTimeout must be a positive number or 'none'",
}
validators["ConnectionAttempts"] = fieldValidator{
Pattern: regexp.MustCompile(`^[1-9]\d*$`),
Message: "ConnectionAttempts must be a positive number",
}
validators["ServerAliveInterval"] = fieldValidator{
Pattern: regexp.MustCompile(`^\d+$`),
Validate: validateNonNegativeNumber,
Message: "ServerAliveInterval must be a non-negative number",
}
validators["ServerAliveCountMax"] = fieldValidator{
Pattern: regexp.MustCompile(`^\d+$`),
Validate: validateNonNegativeNumber,
Message: "ServerAliveCountMax must be a non-negative number",
}
validators["IPQoS"] = fieldValidator{
Validate: validateIPQoS,
Message: "IPQoS must be valid QoS values (e.g., 'af21 cs1', 'lowdelay', 'ef')",
}
// Address and forwarding fields
validators["BindAddress"] = fieldValidator{
Validate: validateBindAddress,
Message: "BindAddress must be a valid IP address, hostname, or '*'",
}
validators["LocalForward"] = fieldValidator{
Validate: validatePortForward,
Message: "LocalForward must be in format '[bind_address:]port:host:hostport'",
}
validators["RemoteForward"] = fieldValidator{
Validate: validatePortForward,
Message: "RemoteForward must be in format '[bind_address:]port:host:hostport'",
}
validators["DynamicForward"] = fieldValidator{
Validate: validateDynamicForward,
Message: "DynamicForward must be in format '[bind_address:]port'",
}
// Authentication fields
validators["NumberOfPasswordPrompts"] = fieldValidator{
Pattern: regexp.MustCompile(`^\d+$`),
Validate: validatePasswordPrompts,
Message: "NumberOfPasswordPrompts must be between 0 and 10",
}
// Advanced fields
validators["CanonicalizeMaxDots"] = fieldValidator{
Pattern: regexp.MustCompile(`^\d+$`),
Validate: validateNonNegativeNumber,
Message: "CanonicalizeMaxDots must be a non-negative number",
}
validators["EscapeChar"] = fieldValidator{
Validate: validateEscapeChar,
Message: "EscapeChar must be a single character, 'none', or ^X format (e.g., ^A)",
}
// Security fields
validators["UserKnownHostsFile"] = fieldValidator{
Validate: validateKnownHostsFiles,
Message: "Known hosts file not found or not accessible",
}
return validators
}
// validatePort validates port number
func validatePort(value string) error {
if value == "" {
return nil // Port is optional
}
port, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid port number")
}
if port < 1 || port > 65535 {
return fmt.Errorf("port must be between 1 and 65535")
}
return nil
}
// validateConnectTimeout validates connection timeout
func validateConnectTimeout(value string) error {
if value == "" || value == "none" {
return nil
}
timeout, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid timeout value")
}
if timeout <= 0 {
return fmt.Errorf("timeout must be positive or 'none'")
}
return nil
}
// validateNonNegativeNumber validates that a value is a non-negative number
func validateNonNegativeNumber(value string) error {
if value == "" {
return nil
}
num, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid number")
}
if num < 0 {
return fmt.Errorf("must be non-negative")
}
return nil
}
// validatePasswordPrompts validates NumberOfPasswordPrompts
func validatePasswordPrompts(value string) error {
if value == "" {
return nil
}
num, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid number")
}
if num < 0 || num > 10 {
return fmt.Errorf("must be between 0 and 10")
}
return nil
}
// validateEscapeChar validates escape character format
func validateEscapeChar(value string) error {
if value == "" || value == "none" || value == "~" {
return nil
}
// Support ^X format (Ctrl+X)
if len(value) == 2 && value[0] == '^' {
char := value[1]
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') {
return nil
}
}
// Single printable character
if len(value) == 1 && value[0] >= 32 && value[0] <= 126 {
return nil
}
return fmt.Errorf("invalid escape character format")
}
// validateIPQoS validates IPQoS values
func validateIPQoS(value string) error {
if value == "" {
return nil
}
validValues := map[string]bool{
"af11": true, "af12": true, "af13": true,
"af21": true, "af22": true, "af23": true,
"af31": true, "af32": true, "af33": true,
"af41": true, "af42": true, "af43": true,
"cs0": true, "cs1": true, "cs2": true, "cs3": true,
"cs4": true, "cs5": true, "cs6": true, "cs7": true,
"ef": true, "le": true,
"lowdelay": true, "throughput": true, "reliability": true, "none": true,
}
// Can be single value or two space-separated values
parts := strings.Fields(value)
if len(parts) > 2 {
return fmt.Errorf("IPQoS accepts at most 2 values")
}
for _, part := range parts {
if !validValues[strings.ToLower(part)] {
return fmt.Errorf("invalid IPQoS value: %s", part)
}
}
return nil
}
// validateFilePath validates a single file path for existence and readability
func validateFilePath(path string) (exists bool, accessible bool, isDir bool) {
// Get home directory for tilde expansion
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = ""
}
// Expand tilde notation
expandedPath := path
if strings.HasPrefix(path, "~/") && homeDir != "" {
expandedPath = filepath.Join(homeDir, path[2:])
} else if strings.HasPrefix(path, "~") && homeDir != "" {
// Handle ~ alone
expandedPath = homeDir
}
// Check if file exists
info, err := os.Stat(expandedPath)
if err != nil {
if os.IsNotExist(err) {
return false, false, false // File doesn't exist
}
// Permission denied or other error
return true, false, false // File exists but not accessible
}
// Check if it's a directory
if info.IsDir() {
return true, true, true
}
// Check if file is readable
// #nosec G304 - expandedPath is validated user input
file, err := os.Open(expandedPath)
if err != nil {
return true, false, false // File exists but not readable
}
_ = file.Close()
return true, true, false // File exists and is readable
}
// buildFileValidationError builds an error message from invalid and inaccessible file paths
func buildFileValidationError(invalidPaths, inaccessiblePaths []string) error {
var errors []string
if len(invalidPaths) > 0 {
errors = append(errors, fmt.Sprintf("file(s) not found: %s", strings.Join(invalidPaths, ", ")))
}
if len(inaccessiblePaths) > 0 {
errors = append(errors, fmt.Sprintf("file(s) not accessible: %s", strings.Join(inaccessiblePaths, ", ")))
}
if len(errors) > 0 {
return fmt.Errorf("%s", strings.Join(errors, "; "))
}
return nil
}
// validateFilePaths validates multiple file paths with a custom separator
func validateFilePaths(files string, separator string) error {
if files == "" {
return nil
}
// Check for invalid characters first, before trimming
if strings.ContainsAny(files, "\n\r\t") {
return fmt.Errorf("file path contains invalid characters")
}
var paths []string
if separator == " " {
// For space separator, use Fields to handle multiple spaces
paths = strings.Fields(files)
} else {
// For other separators like comma
paths = strings.Split(files, separator)
}
var invalidPaths []string
var inaccessiblePaths []string
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
exists, accessible, isDir := validateFilePath(path)
switch {
case !exists:
invalidPaths = append(invalidPaths, path)
case isDir:
invalidPaths = append(invalidPaths, fmt.Sprintf("%s (is a directory)", path))
case !accessible:
inaccessiblePaths = append(inaccessiblePaths, path)
}
}
return buildFileValidationError(invalidPaths, inaccessiblePaths)
}
// validateKeyPaths validates SSH key file paths (comma-separated)
func validateKeyPaths(keys string) error {
return validateFilePaths(keys, ",")
}
// validateKnownHostsFiles validates known_hosts file paths (space-separated)
func validateKnownHostsFiles(files string) error {
// Empty is valid - SSH will use default
return validateFilePaths(files, " ")
}
// validateHost validates a hostname or IP address
func validateHost(host string) error {
if host == "" {
return fmt.Errorf("host is required")
}
// Check for spaces
if strings.Contains(host, " ") {
return fmt.Errorf("host cannot contain spaces")
}
// Try to parse as IP address first
if net.ParseIP(host) != nil {
return nil
}
// Validate as hostname
return validateHostname(host)
}
// validateHostname validates a hostname (not IP)
func validateHostname(host string) error {
if len(host) > 253 {
return fmt.Errorf("hostname too long")
}
// Check for invalid characters using a single check
if strings.ContainsAny(host, invalidHostChars) {
return fmt.Errorf("host contains invalid characters")
}
// Check hostname format
if strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
return fmt.Errorf("hostname cannot start or end with a dot")
}
if strings.Contains(host, "..") {
return fmt.Errorf("hostname cannot contain consecutive dots")
}
// Validate each label
return validateHostLabels(host)
}
// validateHostLabels validates each label in a hostname
func validateHostLabels(host string) error {
labels := strings.Split(host, ".")
for _, label := range labels {
if err := validateHostLabel(label); err != nil {
return err
}
}
return nil
}
// validateHostLabel validates a single hostname label
func validateHostLabel(label string) error {
if label == "" {
return fmt.Errorf("hostname has empty label")
}
if len(label) > 63 {
return fmt.Errorf("hostname label too long")
}
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return fmt.Errorf("hostname label cannot start or end with hyphen")
}
return nil
}
// validatePortForward validates port forwarding specification
func validatePortForward(forward string) error {
if forward == "" {
return nil // Port forwarding is optional
}
// Support multiple forwards separated by comma
forwards := strings.Split(forward, ",")
for _, fwd := range forwards {
fwd = strings.TrimSpace(fwd)
if fwd == "" {
continue
}
// Format: [bind_address:]port:host:hostport
parts := strings.Split(fwd, ":")
if len(parts) < 3 || len(parts) > 4 {
return fmt.Errorf("invalid format, expected [bind_address:]port:host:hostport")
}
// Validate ports
var portIdx, hostPortIdx int
if len(parts) == 3 {
// port:host:hostport
portIdx = 0
hostPortIdx = 2
} else {
// bind_address:port:host:hostport
portIdx = 1
hostPortIdx = 3
// Validate bind address
if parts[0] != "" && parts[0] != "*" {
if err := validateBindAddress(parts[0]); err != nil {
return fmt.Errorf("invalid bind address: %w", err)
}
}
}
// Validate port numbers
port, err := strconv.Atoi(parts[portIdx])
if err != nil || port < 1 || port > 65535 {
return fmt.Errorf("invalid port number: %s", parts[portIdx])
}
hostPort, err := strconv.Atoi(parts[hostPortIdx])
if err != nil || hostPort < 1 || hostPort > 65535 {
return fmt.Errorf("invalid host port number: %s", parts[hostPortIdx])
}
}
return nil
}
// validateDynamicForward validates dynamic port forwarding specification
func validateDynamicForward(forward string) error {
if forward == "" {
return nil // Dynamic forwarding is optional
}
// Support multiple forwards separated by comma
forwards := strings.Split(forward, ",")
for _, fwd := range forwards {
fwd = strings.TrimSpace(fwd)
if fwd == "" {
continue
}
// Format: [bind_address:]port
parts := strings.Split(fwd, ":")
if len(parts) > 2 {
return fmt.Errorf("invalid format, expected [bind_address:]port")
}
var portStr string
if len(parts) == 1 {
// Just port
portStr = parts[0]
} else {
// bind_address:port
if parts[0] != "" && parts[0] != "*" {
if err := validateBindAddress(parts[0]); err != nil {
return fmt.Errorf("invalid bind address: %w", err)
}
}
portStr = parts[1]
}
// Validate port number
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return fmt.Errorf("invalid port number: %s", portStr)
}
}
return nil
}
// validateBindAddress validates a bind address (IP, hostname, or *)
func validateBindAddress(address string) error {
if address == "" || address == "*" {
return nil // Empty or wildcard is valid
}
// Check for spaces
if strings.Contains(address, " ") {
return fmt.Errorf("address cannot contain spaces")
}
// Try to parse as IP address first (including IPv6)
if net.ParseIP(address) != nil {
return nil
}
// Validate as hostname with relaxed rules
return validateBindHostname(address)
}
// isNumericDottedFormat checks if the address looks like an IP address (contains only dots and digits)
func isNumericDottedFormat(address string) bool {
for _, ch := range address {
if ch != '.' && (ch < '0' || ch > '9') {
return false
}
}
return strings.Contains(address, ".")
}
// validateBindHostname validates a hostname for bind address (more permissive than regular hostname)
func validateBindHostname(address string) error {
// Check for invalid characters using a single check
if strings.ContainsAny(address, invalidAddressChars) {
return fmt.Errorf("address contains invalid characters")
}
// Check hostname format
if strings.HasPrefix(address, ".") || strings.HasSuffix(address, ".") {
return fmt.Errorf("address cannot start or end with a dot")
}
if strings.HasPrefix(address, "-") || strings.HasSuffix(address, "-") {
return fmt.Errorf("address cannot start or end with hyphen")
}
// Check for consecutive dots
if strings.Contains(address, "..") {
return fmt.Errorf("address cannot contain consecutive dots")
}
// If it looks like an IP address (contains only dots and digits), validate it more strictly
if isNumericDottedFormat(address) {
// Check if all segments are valid numbers
segments := strings.Split(address, ".")
// IPv4 should have exactly 4 segments
if len(segments) == 4 {
for _, seg := range segments {
if seg == "" {
return fmt.Errorf("invalid IP address format")
}
num, err := strconv.Atoi(seg)
if err != nil || num < 0 || num > 255 {
return fmt.Errorf("invalid IP address format")
}
}
return nil // Valid IPv4
}
// If it's not 4 segments but looks numeric, it's invalid
return fmt.Errorf("invalid address format")
}
// Check each label for hyphens at start/end
if strings.Contains(address, ".") {
return validateAddressLabels(address)
}
return nil
}
// validateAddressLabels validates labels in a bind address
func validateAddressLabels(address string) error {
labels := strings.Split(address, ".")
for _, label := range labels {
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return fmt.Errorf("address label cannot start or end with hyphen")
}
}
return nil
}
// stripColorTags removes tview color tags from a string
func stripColorTags(s string) string {
// Remove all tview color tags like [red], [-], [yellow], etc.
colorTagRegex := regexp.MustCompile(`\[[^\]]*\]`)
return colorTagRegex.ReplaceAllString(s, "")
}