"""Input validation and security utilities for Aseprite MCP."""
import os
import re
from pathlib import Path
from typing import Optional, Union
from .exceptions import ValidationError, SecurityError
from .config import get_config
def validate_file_path(file_path: str, must_exist: bool = False, allow_new: bool = True) -> Path:
"""
Validate and sanitize file paths to prevent security issues.
Args:
file_path: The file path to validate
must_exist: If True, file must already exist
allow_new: If True, allow paths that don't exist yet
Returns:
Path: The validated path object
Raises:
ValidationError: If validation fails
SecurityError: If path traversal is detected
"""
if not file_path:
raise ValidationError("file_path", file_path, "Path cannot be empty")
# Normalize path and check for path traversal
try:
path = Path(file_path).resolve()
except (ValueError, RuntimeError) as e:
raise ValidationError("file_path", file_path, f"Invalid path: {e}")
# Get configuration
config = get_config()
# Check for path traversal attempts if not allowed
if not config.security.allow_path_traversal and ".." in str(file_path):
raise SecurityError(
"Path traversal detected",
f"Attempted to access: {file_path}"
)
# Check if path is in allowed directories
if not config.is_path_allowed(path):
raise SecurityError(
"Access to path is not allowed",
f"Path '{path}' is outside allowed directories"
)
# Check file size limits for existing files
if path.exists() and path.is_file():
file_size = path.stat().st_size
if file_size > config.security.max_file_size:
raise ValidationError(
"file_path",
str(path),
f"File size ({file_size} bytes) exceeds maximum allowed ({config.security.max_file_size} bytes)"
)
# Check existence
if must_exist and not path.exists():
raise ValidationError("file_path", str(path), "File does not exist")
if not allow_new and not path.exists():
raise ValidationError("file_path", str(path), "Creating new files is not allowed")
# Check if parent directory exists for new files
if not path.exists() and allow_new and not path.parent.exists():
raise ValidationError(
"file_path",
str(path),
f"Parent directory does not exist: {path.parent}"
)
return path
def validate_color(color: str) -> str:
"""
Validate and normalize color values.
Args:
color: Color value as hex string (e.g., "#FF0000" or "FF0000")
Returns:
str: Normalized color value without '#'
Raises:
ValidationError: If color format is invalid
"""
if not color:
raise ValidationError("color", color, "Color cannot be empty")
# Remove '#' if present
color = color.lstrip('#')
# Validate hex format
if not re.match(r'^[0-9A-Fa-f]{6}$', color):
raise ValidationError(
"color",
color,
"Color must be a 6-character hex value (e.g., 'FF0000')"
)
return color.upper()
def validate_dimensions(width: int, height: int, max_size: Optional[int] = None) -> tuple[int, int]:
"""
Validate image dimensions.
Args:
width: Image width in pixels
height: Image height in pixels
max_size: Maximum allowed dimension (uses config if not specified)
Returns:
tuple: Validated (width, height)
Raises:
ValidationError: If dimensions are invalid
"""
config = get_config()
if max_size is None:
max_width = config.canvas.max_width
max_height = config.canvas.max_height
else:
max_width = max_height = max_size
if width <= 0:
raise ValidationError("width", width, "Width must be positive")
if height <= 0:
raise ValidationError("height", height, "Height must be positive")
if width > max_width:
raise ValidationError("width", width, f"Width exceeds maximum of {max_width}")
if height > max_height:
raise ValidationError("height", height, f"Height exceeds maximum of {max_height}")
return width, height
def validate_coordinates(x: int, y: int, width: int, height: int) -> tuple[int, int]:
"""
Validate that coordinates are within bounds.
Args:
x: X coordinate
y: Y coordinate
width: Canvas width
height: Canvas height
Returns:
tuple: Validated (x, y)
Raises:
ValidationError: If coordinates are out of bounds
"""
if x < 0 or x >= width:
raise ValidationError("x", x, f"X coordinate must be between 0 and {width-1}")
if y < 0 or y >= height:
raise ValidationError("y", y, f"Y coordinate must be between 0 and {height-1}")
return x, y
def validate_export_format(format: str) -> str:
"""
Validate export format.
Args:
format: Export format (e.g., 'png', 'gif', 'jpg')
Returns:
str: Validated format in lowercase
Raises:
ValidationError: If format is not supported
"""
valid_formats = {'png', 'gif', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp'}
format_lower = format.lower()
if format_lower not in valid_formats:
raise ValidationError(
"format",
format,
f"Format must be one of: {', '.join(sorted(valid_formats))}"
)
return format_lower
def sanitize_lua_string(value: str) -> str:
"""
Sanitize string for safe inclusion in Lua scripts.
Args:
value: String to sanitize
Returns:
str: Sanitized string safe for Lua
"""
# Escape quotes and backslashes
sanitized = value.replace('\\', '\\\\').replace('"', '\\"')
# Check for restricted Lua functions
config = get_config()
for restricted in config.security.restricted_lua_functions:
if restricted in sanitized:
raise SecurityError(
f"Restricted Lua function '{restricted}' detected",
f"Attempted to use: {restricted}"
)
return sanitized
def validate_layer_name(name: str) -> str:
"""
Validate layer name.
Args:
name: Layer name
Returns:
str: Validated layer name
Raises:
ValidationError: If name is invalid
"""
if not name:
raise ValidationError("layer_name", name, "Layer name cannot be empty")
if len(name) > 255:
raise ValidationError("layer_name", name, "Layer name too long (max 255 characters)")
# Remove potentially problematic characters
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
for char in invalid_chars:
if char in name:
raise ValidationError(
"layer_name",
name,
f"Layer name cannot contain: {' '.join(invalid_chars)}"
)
return name