""" Validadores para arquivos de entrada. Este módulo contém funções para validar arquivos antes do processamento, incluindo verificação de tamanho, contagem, MIME type e sanitização de nomes. """ import os import re from pathlib import Path from typing import BinaryIO import config # Tenta importar python-magic, mas oferece fallback se não disponível try: import magic HAS_MAGIC = True except ImportError: HAS_MAGIC = False class ValidationError(Exception): """Exceção levantada quando uma validação falha.""" def __init__(self, message: str, error_code: str = "VALIDATION_ERROR"): self.message = message self.error_code = error_code super().__init__(self.message) def validate_file_count(files: list) -> bool: """ Valida se o número de arquivos está dentro do limite permitido. Args: files: Lista de arquivos para validar. Returns: True se a contagem está válida. Raises: ValidationError: Se houver arquivos demais ou nenhum arquivo. """ if not files: raise ValidationError( "Nenhum arquivo enviado. Por favor, selecione ao menos um arquivo.", error_code="NO_FILES" ) if len(files) > config.MAX_FILES_PER_SESSION: raise ValidationError( f"Muitos arquivos! Máximo permitido: {config.MAX_FILES_PER_SESSION}. " f"Você enviou: {len(files)}.", error_code="TOO_MANY_FILES" ) return True def validate_file_size(file_path: str | Path) -> bool: """ Valida se o tamanho do arquivo está dentro do limite permitido. Args: file_path: Caminho para o arquivo a ser validado. Returns: True se o tamanho está válido. Raises: ValidationError: Se o arquivo for muito grande. """ file_path = Path(file_path) if not file_path.exists(): raise ValidationError( f"Arquivo não encontrado: {file_path.name}", error_code="FILE_NOT_FOUND" ) file_size = file_path.stat().st_size if file_size > config.MAX_FILE_SIZE_BYTES: size_mb = file_size / (1024 * 1024) raise ValidationError( f"Arquivo muito grande: {file_path.name} ({size_mb:.1f}MB). " f"Máximo permitido: {config.MAX_FILE_SIZE_MB}MB.", error_code="FILE_TOO_LARGE" ) if file_size == 0: raise ValidationError( f"Arquivo vazio: {file_path.name}", error_code="EMPTY_FILE" ) return True def _get_mime_type_magic(file_path: str | Path) -> str: """ Obtém o MIME type usando python-magic. Args: file_path: Caminho para o arquivo. Returns: String com o MIME type detectado. """ mime = magic.Magic(mime=True) return mime.from_file(str(file_path)) def _get_mime_type_fallback(file_path: str | Path) -> str: """ Fallback para detecção de MIME type sem python-magic. Usa assinaturas de arquivo (magic bytes). Args: file_path: Caminho para o arquivo. Returns: String com o MIME type detectado ou extensão-based guess. """ file_path = Path(file_path) # Magic bytes para tipos comuns signatures = { b"%PDF": "application/pdf", b"PK\x03\x04": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", b"\xd0\xcf\x11\xe0": "application/msword", # OLE Compound Document } try: with open(file_path, "rb") as f: header = f.read(8) for sig, mime_type in signatures.items(): if header.startswith(sig): return mime_type except Exception: pass # Fallback para extensão ext = file_path.suffix.lower() ext_to_mime = { ".pdf": "application/pdf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", } return ext_to_mime.get(ext, "application/octet-stream") def get_mime_type(file_path: str | Path) -> str: """ Obtém o MIME type de um arquivo. Usa python-magic se disponível, caso contrário usa fallback baseado em assinaturas de arquivo. Args: file_path: Caminho para o arquivo. Returns: String com o MIME type detectado. """ if HAS_MAGIC: try: return _get_mime_type_magic(file_path) except Exception: # Fallback se libmagic falhar em runtime (OSError, etc) pass return _get_mime_type_fallback(file_path) def validate_mime_type(file_path: str | Path) -> bool: """ Valida se o MIME type do arquivo é suportado. Args: file_path: Caminho para o arquivo a ser validado. Returns: True se o MIME type é válido. Raises: ValidationError: Se o tipo de arquivo não for suportado. """ file_path = Path(file_path) extension = file_path.suffix.lower() # Verifica se a extensão é suportada if extension not in config.SUPPORTED_EXTENSIONS: raise ValidationError( f"Extensão não suportada: {extension}. " f"Tipos aceitos: {', '.join(config.SUPPORTED_EXTENSIONS)}", error_code="UNSUPPORTED_EXTENSION" ) # Obtém o MIME type real do arquivo detected_mime = get_mime_type(file_path) # Verifica se o MIME type corresponde à extensão expected_mimes = config.SUPPORTED_MIME_TYPES.get(extension, []) if detected_mime not in expected_mimes: # DOCX pode ser detectado como ZIP em alguns casos if extension == ".docx" and detected_mime == "application/zip": return True raise ValidationError( f"Tipo de arquivo inválido: {file_path.name}. " f"O conteúdo não corresponde à extensão {extension}. " f"Detectado: {detected_mime}", error_code="MIME_MISMATCH" ) return True def sanitize_filename(filename: str) -> str: """ Remove caracteres especiais/perigosos do nome de arquivo. Args: filename: Nome original do arquivo. Returns: Nome de arquivo sanitizado. """ if not filename: return "arquivo_sem_nome" # Remove caracteres proibidos for char in config.FORBIDDEN_FILENAME_CHARS: filename = filename.replace(char, "_") # Remove caracteres de controle filename = re.sub(r"[\x00-\x1f\x7f]", "", filename) # Substitui espaços múltiplos por um único underscore filename = re.sub(r"\s+", "_", filename) # Remove underscores múltiplos filename = re.sub(r"_+", "_", filename) # Remove underscores no início e fim filename = filename.strip("_") # Limita o comprimento if len(filename) > config.FILENAME_MAX_LENGTH: # Preserva a extensão name, ext = os.path.splitext(filename) max_name_len = config.FILENAME_MAX_LENGTH - len(ext) filename = name[:max_name_len] + ext # Se ficou vazio após sanitização if not filename or filename == "." or filename == "..": return "arquivo_sanitizado" return filename def validate_files(files: list) -> list[tuple[Path, str]]: """ Valida uma lista de arquivos completamente. Args: files: Lista de arquivos (podem ser paths ou objetos de arquivo). Returns: Lista de tuplas (path, nome_sanitizado) para arquivos válidos. Raises: ValidationError: Se qualquer validação falhar. """ validate_file_count(files) validated = [] for file_obj in files: # Gradio retorna objetos com atributo 'name' if hasattr(file_obj, "name"): file_path = Path(file_obj.name) else: file_path = Path(file_obj) # Valida tamanho validate_file_size(file_path) # Valida MIME type validate_mime_type(file_path) # Sanitiza nome sanitized_name = sanitize_filename(file_path.name) validated.append((file_path, sanitized_name)) return validated