Files
Jackify/jackify/backend/handlers/validation_handler.py
Omni cd591c14e3 Initial public release v0.1.0 - Linux Wabbajack Modlist Application
Jackify provides native Linux support for Wabbajack modlist installation
   and management with automated Steam integration and Proton configuration.

   Key Features:
   - Almost Native Linux implementation (texconv.exe run via proton)
   - Automated Steam shortcut creation and Proton prefix management
   - Both CLI and GUI interfaces, with Steam Deck optimization

   Supported Games:
   - Skyrim Special Edition
   - Fallout 4
   - Fallout New Vegas
   - Oblivion, Starfield, Enderal, and diverse other games

   Technical Architecture:
   - Clean separation between frontend and backend services
   - Powered by jackify-engine 0.3.x for Wabbajack-matching modlist installation
2025-09-05 20:46:24 +01:00

318 lines
14 KiB
Python

"""
ValidationHandler module for managing validation operations.
This module handles input validation, path validation, and configuration validation.
"""
import os
import logging
import re
import shutil
import vdf
from pathlib import Path
from typing import Optional, Dict, List, Tuple, Any
class ValidationHandler:
def __init__(self):
self.logger = logging.getLogger(__name__)
def validate_path(self, path: Path, must_exist: bool = True) -> Tuple[bool, str]:
"""Validate a path."""
try:
if not isinstance(path, Path):
return False, "Path must be a Path object"
if must_exist and not path.exists():
return False, f"Path does not exist: {path}"
if not os.access(path, os.R_OK | os.W_OK):
return False, f"Path is not accessible: {path}"
return True, "Path is valid"
except Exception as e:
self.logger.error(f"Failed to validate path {path}: {e}")
return False, str(e)
def validate_input(self, value: Any, rules: Dict) -> Tuple[bool, str]:
"""Validate user input against rules."""
try:
# Check required
if rules.get('required', False) and not value:
return False, "Value is required"
# Check type
if 'type' in rules and not isinstance(value, rules['type']):
return False, f"Value must be of type {rules['type'].__name__}"
# Check min/max length for strings
if isinstance(value, str):
if 'min_length' in rules and len(value) < rules['min_length']:
return False, f"Value must be at least {rules['min_length']} characters"
if 'max_length' in rules and len(value) > rules['max_length']:
return False, f"Value must be at most {rules['max_length']} characters"
# Check min/max value for numbers
if isinstance(value, (int, float)):
if 'min_value' in rules and value < rules['min_value']:
return False, f"Value must be at least {rules['min_value']}"
if 'max_value' in rules and value > rules['max_value']:
return False, f"Value must be at most {rules['max_value']}"
# Check pattern for strings
if isinstance(value, str) and 'pattern' in rules:
if not re.match(rules['pattern'], value):
return False, f"Value must match pattern: {rules['pattern']}"
# Check custom validation function
if 'validate' in rules and callable(rules['validate']):
result = rules['validate'](value)
if isinstance(result, tuple):
return result
elif not result:
return False, "Custom validation failed"
return True, "Input is valid"
except Exception as e:
self.logger.error(f"Failed to validate input: {e}")
return False, str(e)
def validate_config(self, config: Dict, schema: Dict) -> Tuple[bool, List[str]]:
"""Validate configuration against a schema."""
try:
errors = []
# Check required fields
for field, rules in schema.items():
if rules.get('required', False) and field not in config:
errors.append(f"Missing required field: {field}")
# Check field types and values
for field, value in config.items():
if field not in schema:
errors.append(f"Unknown field: {field}")
continue
rules = schema[field]
if 'type' in rules and not isinstance(value, rules['type']):
errors.append(f"Invalid type for {field}: expected {rules['type'].__name__}")
if isinstance(value, str):
if 'min_length' in rules and len(value) < rules['min_length']:
errors.append(f"{field} must be at least {rules['min_length']} characters")
if 'max_length' in rules and len(value) > rules['max_length']:
errors.append(f"{field} must be at most {rules['max_length']} characters")
if 'pattern' in rules and not re.match(rules['pattern'], value):
errors.append(f"{field} must match pattern: {rules['pattern']}")
if isinstance(value, (int, float)):
if 'min_value' in rules and value < rules['min_value']:
errors.append(f"{field} must be at least {rules['min_value']}")
if 'max_value' in rules and value > rules['max_value']:
errors.append(f"{field} must be at most {rules['max_value']}")
if 'validate' in rules and callable(rules['validate']):
result = rules['validate'](value)
if isinstance(result, tuple):
if not result[0]:
errors.append(f"{field}: {result[1]}")
elif not result:
errors.append(f"Custom validation failed for {field}")
return len(errors) == 0, errors
except Exception as e:
self.logger.error(f"Failed to validate config: {e}")
return False, [str(e)]
def validate_dependencies(self, dependencies: List[str]) -> Tuple[bool, List[str]]:
"""Validate system dependencies."""
try:
missing = []
for dep in dependencies:
if not shutil.which(dep):
missing.append(dep)
return len(missing) == 0, missing
except Exception as e:
self.logger.error(f"Failed to validate dependencies: {e}")
return False, [str(e)]
def validate_game_installation(self, game_type: str, path: Path) -> Tuple[bool, str]:
"""Validate a game installation."""
try:
# Check if path exists
if not path.exists():
return False, f"Game path does not exist: {path}"
# Check if path is accessible
if not os.access(path, os.R_OK | os.W_OK):
return False, f"Game path is not accessible: {path}"
# Check for game-specific files
if game_type == 'skyrim':
if not (path / 'SkyrimSE.exe').exists():
return False, "SkyrimSE.exe not found"
elif game_type == 'fallout4':
if not (path / 'Fallout4.exe').exists():
return False, "Fallout4.exe not found"
elif game_type == 'falloutnv':
if not (path / 'FalloutNV.exe').exists():
return False, "FalloutNV.exe not found"
elif game_type == 'oblivion':
if not (path / 'Oblivion.exe').exists():
return False, "Oblivion.exe not found"
else:
return False, f"Unknown game type: {game_type}"
return True, "Game installation is valid"
except Exception as e:
self.logger.error(f"Failed to validate game installation: {e}")
return False, str(e)
def validate_modlist(self, modlist_path: Path) -> Tuple[bool, List[str]]:
"""Validate a modlist installation."""
try:
errors = []
# Check if path exists
if not modlist_path.exists():
errors.append(f"Modlist path does not exist: {modlist_path}")
return False, errors
# Check if path is accessible
if not os.access(modlist_path, os.R_OK | os.W_OK):
errors.append(f"Modlist path is not accessible: {modlist_path}")
return False, errors
# Check for ModOrganizer.ini
if not (modlist_path / 'ModOrganizer.ini').exists():
errors.append("ModOrganizer.ini not found")
# Check for mods directory
if not (modlist_path / 'mods').exists():
errors.append("mods directory not found")
# Check for profiles directory
if not (modlist_path / 'profiles').exists():
errors.append("profiles directory not found")
return len(errors) == 0, errors
except Exception as e:
self.logger.error(f"Failed to validate modlist: {e}")
return False, [str(e)]
def validate_wine_prefix(self, app_id: str) -> Tuple[bool, str]:
"""Validate a Wine prefix."""
try:
# Check if prefix exists
prefix_path = Path.home() / '.steam' / 'steam' / 'steamapps' / 'compatdata' / app_id / 'pfx'
if not prefix_path.exists():
return False, f"Wine prefix does not exist: {prefix_path}"
# Check if prefix is accessible
if not os.access(prefix_path, os.R_OK | os.W_OK):
return False, f"Wine prefix is not accessible: {prefix_path}"
# Check for system.reg
if not (prefix_path / 'system.reg').exists():
return False, "system.reg not found"
return True, "Wine prefix is valid"
except Exception as e:
self.logger.error(f"Failed to validate Wine prefix: {e}")
return False, str(e)
def validate_steam_shortcut(self, app_id: str) -> Tuple[bool, str]:
"""Validate a Steam shortcut."""
try:
# Check if shortcuts.vdf exists
shortcuts_path = Path.home() / '.steam' / 'steam' / 'userdata' / '75424832' / 'config' / 'shortcuts.vdf'
if not shortcuts_path.exists():
return False, "shortcuts.vdf not found"
# Check if shortcuts.vdf is accessible
if not os.access(shortcuts_path, os.R_OK | os.W_OK):
return False, "shortcuts.vdf is not accessible"
# Parse shortcuts.vdf using VDFHandler
shortcuts_data = VDFHandler.load(str(shortcuts_path), binary=True)
# Check if shortcut exists
for shortcut in shortcuts_data.get('shortcuts', {}).values():
if str(shortcut.get('appid')) == app_id:
return True, "Steam shortcut is valid"
return False, f"Steam shortcut not found: {app_id}"
except Exception as e:
self.logger.error(f"Failed to validate Steam shortcut: {e}")
return False, str(e)
def validate_resolution(self, resolution: str) -> Tuple[bool, str]:
"""Validate a resolution string."""
try:
# Check format
if not re.match(r'^\d+x\d+$', resolution):
return False, "Resolution must be in format WIDTHxHEIGHT"
# Parse dimensions
width, height = map(int, resolution.split('x'))
# Check minimum dimensions
if width < 640 or height < 480:
return False, "Resolution must be at least 640x480"
# Check maximum dimensions
if width > 7680 or height > 4320:
return False, "Resolution must be at most 7680x4320"
return True, "Resolution is valid"
except Exception as e:
self.logger.error(f"Failed to validate resolution: {e}")
return False, str(e)
def validate_permissions(self, path: Path, required_permissions: int) -> Tuple[bool, str]:
"""Validate file or directory permissions."""
try:
# Get current permissions
current_permissions = os.stat(path).st_mode & 0o777
# Check if current permissions include required permissions
if current_permissions & required_permissions != required_permissions:
return False, f"Missing required permissions: {required_permissions:o}"
return True, "Permissions are valid"
except Exception as e:
self.logger.error(f"Failed to validate permissions: {e}")
return False, str(e)
def is_dangerous_directory(self, path: Path) -> bool:
"""Return True if the directory is a dangerous system or user root directory."""
dangerous = [
Path('/'), Path('/home'), Path('/root'), Path('/etc'), Path('/usr'), Path('/bin'), Path('/lib'),
Path('/opt'), Path('/var'), Path('/tmp'), Path.home()
]
abs_path = path.resolve()
return any(abs_path == d.resolve() for d in dangerous)
def looks_like_modlist_dir(self, path: Path) -> bool:
"""Return True if the directory contains files/folders typical of a modlist install."""
expected = [
'ModOrganizer.exe', 'profiles', 'mods', 'downloads', '.wabbajack', '.jackify_modlist_marker', 'ModOrganizer.ini'
]
for item in expected:
if (path / item).exists():
return True
return False
def has_jackify_marker(self, path: Path) -> bool:
"""Return True if the directory contains a .jackify_modlist_marker file."""
return (path / '.jackify_modlist_marker').exists()
def is_safe_install_directory(self, path: Path) -> (bool, str):
"""Check if the directory is safe for install. Returns (True, reason) or (False, warning)."""
if self.is_dangerous_directory(path):
return False, f"The directory '{path}' is a system or user root and cannot be used for modlist installation."
if not path.exists():
return True, "Directory does not exist and will be created."
if not any(path.iterdir()):
return True, "Directory is empty."
if self.looks_like_modlist_dir(path):
return True, "Directory looks like a valid modlist install."
return False, f"The directory '{path}' is not empty and does not look like a valid modlist install. Please choose an empty directory or a valid modlist directory."