jurix-api / app /utils /file_utils.py
rahim237cm's picture
Claude Opus 5
fix(validation): trois utilitaires de securite qui ne validaient rien
4806809
Raw History Blame Contribute Delete
11.7 kB
"""
Utility functions for file operations.
Provides helpers for:
- File hashing (SHA-256)
- File type detection (magic bytes)
- File validation (PDF, DOCX structure)
- Filename generation (UUID-based)
- Storage management
Author: JuriX Development Team
Date: 2026-01-11
"""
import hashlib
import logging
import re
import uuid
from pathlib import Path
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
# File type magic bytes signatures
MAGIC_BYTES = {
"pdf": b"%PDF",
"docx": b"PK\x03\x04", # ZIP signature (DOCX is a ZIP archive)
}
# MIME types
MIME_TYPES = {
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
def get_file_hash(file_path: Path, algorithm: str = "sha256") -> str:
"""
Calculate hash of a file.
Args:
file_path: Path to file
algorithm: Hash algorithm (default: sha256)
Returns:
Hex digest of file hash
Raises:
FileNotFoundError: If file doesn't exist
ValueError: If algorithm not supported
Example:
>>> hash_value = get_file_hash(Path("document.pdf"))
>>> len(hash_value)
64
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
if algorithm not in hashlib.algorithms_available:
raise ValueError(f"Hash algorithm '{algorithm}' not supported")
hash_obj = hashlib.new(algorithm)
# Read file in chunks to handle large files
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
hash_obj.update(chunk)
return hash_obj.hexdigest()
def get_magic_bytes(file_path: Path, num_bytes: int = 4) -> bytes:
"""
Read the first N bytes of a file (magic bytes).
Args:
file_path: Path to file
num_bytes: Number of bytes to read (default: 4)
Returns:
First N bytes of file
Raises:
FileNotFoundError: If file doesn't exist
Example:
>>> magic = get_magic_bytes(Path("document.pdf"))
>>> magic
b'%PDF'
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
with open(file_path, "rb") as f:
return f.read(num_bytes)
def detect_file_type(file_path: Path) -> Optional[str]:
"""
Detect file type from magic bytes.
Args:
file_path: Path to file
Returns:
File type ('pdf' or 'docx') or None if unknown
Example:
>>> detect_file_type(Path("document.pdf"))
'pdf'
"""
try:
magic = get_magic_bytes(file_path, num_bytes=4)
for file_type, signature in MAGIC_BYTES.items():
if magic.startswith(signature):
return file_type
return None
except Exception as e:
logger.warning(f"Failed to detect file type for {file_path}: {e}")
return None
def is_valid_pdf(file_path: Path) -> Tuple[bool, Optional[str]]:
"""
Validate PDF file structure.
Checks:
- Magic bytes (%PDF)
- File ends with %%EOF
- Minimum file size
Args:
file_path: Path to PDF file
Returns:
Tuple of (is_valid, error_message)
Example:
>>> is_valid, error = is_valid_pdf(Path("document.pdf"))
>>> is_valid
True
"""
try:
# Check file exists and has minimum size
if not file_path.exists():
return False, "File does not exist"
file_size = file_path.stat().st_size
if file_size < 100: # Minimum PDF size
return False, "File too small to be a valid PDF"
# Check magic bytes
magic = get_magic_bytes(file_path, num_bytes=4)
if not magic.startswith(b"%PDF"):
return False, "Invalid PDF magic bytes"
# Check EOF marker (read last 1024 bytes)
with open(file_path, "rb") as f:
f.seek(max(0, file_size - 1024))
tail = f.read()
if b"%%EOF" not in tail:
return False, "Missing PDF EOF marker"
return True, None
except Exception as e:
return False, f"PDF validation error: {str(e)}"
def is_valid_docx(file_path: Path) -> Tuple[bool, Optional[str]]:
"""
Validate DOCX file structure.
Checks:
- Magic bytes (PK - ZIP signature)
- Contains required DOCX files (word/document.xml)
- Minimum file size
Args:
file_path: Path to DOCX file
Returns:
Tuple of (is_valid, error_message)
Example:
>>> is_valid, error = is_valid_docx(Path("document.docx"))
>>> is_valid
True
"""
try:
# Check file exists and has minimum size
if not file_path.exists():
return False, "File does not exist"
file_size = file_path.stat().st_size
if file_size < 1000: # Minimum DOCX size
return False, "File too small to be a valid DOCX"
# Check magic bytes (ZIP signature)
magic = get_magic_bytes(file_path, num_bytes=4)
if not magic.startswith(b"PK\x03\x04"):
return False, "Invalid DOCX magic bytes (not a ZIP file)"
# Try to open as ZIP and check for required DOCX structure
import zipfile
if not zipfile.is_zipfile(file_path):
return False, "File is not a valid ZIP archive"
with zipfile.ZipFile(file_path, "r") as zip_file:
# Check for required DOCX files
required_files = ["[Content_Types].xml", "word/document.xml"]
zip_contents = zip_file.namelist()
for required_file in required_files:
if required_file not in zip_contents:
return False, f"Missing required DOCX file: {required_file}"
return True, None
except zipfile.BadZipFile:
return False, "Corrupted ZIP/DOCX file"
except Exception as e:
return False, f"DOCX validation error: {str(e)}"
def generate_unique_filename(original_filename: str, preserve_extension: bool = True) -> str:
"""
Generate a unique filename using UUID.
Args:
original_filename: Original filename
preserve_extension: Whether to keep original extension (default: True)
Returns:
Unique filename (UUID-based)
Example:
>>> filename = generate_unique_filename("document.pdf")
>>> filename.endswith(".pdf")
True
>>> len(filename.split(".")[0])
36 # UUID length
"""
unique_id = str(uuid.uuid4())
if preserve_extension:
# Extract extension from original filename
extension = Path(original_filename).suffix
return f"{unique_id}{extension}"
else:
return unique_id
def ensure_storage_directory(path: Path) -> None:
"""
Ensure storage directory exists, create if necessary.
Args:
path: Directory path
Raises:
PermissionError: If directory cannot be created
OSError: If path exists but is not a directory
Example:
>>> ensure_storage_directory(Path("/tmp/uploads"))
"""
if path.exists():
if not path.is_dir():
raise OSError(f"Path exists but is not a directory: {path}")
logger.debug(f"Storage directory already exists: {path}")
else:
path.mkdir(parents=True, exist_ok=True)
logger.info(f"Created storage directory: {path}")
def get_file_size_mb(file_path: Path) -> float:
"""
Get file size in megabytes.
Args:
file_path: Path to file
Returns:
File size in MB (rounded to 2 decimals)
Example:
>>> size_mb = get_file_size_mb(Path("document.pdf"))
>>> size_mb
2.5
"""
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
size_bytes = file_path.stat().st_size
size_mb = size_bytes / (1024 * 1024)
return round(size_mb, 2)
def is_file_size_valid(file_path: Path, max_size_mb: int = 50) -> Tuple[bool, Optional[str]]:
"""
Check if file size is within allowed limit.
Args:
file_path: Path to file
max_size_mb: Maximum allowed size in MB (default: 50)
Returns:
Tuple of (is_valid, error_message)
Example:
>>> is_valid, error = is_file_size_valid(Path("document.pdf"), max_size_mb=50)
>>> is_valid
True
"""
try:
# Les DEUX comparaisons portent sur les octets, jamais sur les Mo.
# get_file_size_mb arrondit a deux decimales : tout fichier de moins de
# ~5 Ko donnait 0.0 et etait rejete comme "File is empty (0 bytes)",
# message faux qui refusait des PDF et DOCX parfaitement valides — un
# decret d'une page pese souvent moins que ca. Symetriquement, un
# fichier de 50,004 Mo s'arrondissait a 50.0 et passait sous la limite.
size_bytes = file_path.stat().st_size
size_mb = round(size_bytes / (1024 * 1024), 2)
if size_bytes > max_size_mb * 1024 * 1024:
return False, f"File size {size_mb} MB exceeds maximum {max_size_mb} MB"
if size_bytes == 0:
return False, "File is empty (0 bytes)"
return True, None
except Exception as e:
return False, f"Size validation error: {str(e)}"
def get_mime_type(file_type: str) -> str:
"""
Get MIME type for file type.
Args:
file_type: File type ('pdf' or 'docx')
Returns:
MIME type string
Raises:
ValueError: If file type not supported
Example:
>>> get_mime_type("pdf")
'application/pdf'
"""
if file_type not in MIME_TYPES:
raise ValueError(f"Unsupported file type: {file_type}")
return MIME_TYPES[file_type]
# ==================== RESOLUTION SECURISEE DES FICHIERS ====================
_FILE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
def resolve_upload_path(file_id: str) -> Path:
"""
Résout un identifiant de fichier en chemin, à l'intérieur du répertoire d'upload.
Motivation : `/api/v1/ocr/detect` et `/api/v1/ocr/process` acceptaient un
`pdf_path` arbitraire — un chemin du système de fichiers du serveur — et se
contentaient d'un `.exists()`. N'importe qui pouvait donc énumérer les
fichiers de la machine (404 contre 500) et se faire renvoyer le texte de tout
document lisible par Tesseract. L'authentification seule ne suffit pas : un
jeton d'administration volé rouvrirait la faille.
Trois barrières indépendantes :
1. le motif rejette `..`, `/`, `%2f`, les chemins absolus et les octets nuls
AVANT tout accès au disque ;
2. `resolve()` + `is_relative_to` bloquent les évasions par lien symbolique ;
3. `is_file()` écarte les répertoires et les fichiers spéciaux.
Args:
file_id: Identifiant du fichier, sans extension
Returns:
Chemin absolu du fichier
Raises:
ValueError: identifiant invalide ou chemin sortant du répertoire
FileNotFoundError: aucun fichier correspondant
"""
if not _FILE_ID_RE.match(file_id or ""):
raise ValueError(f"Identifiant de fichier invalide : {file_id!r}")
from app.services.file_upload_service import get_upload_service
root = get_upload_service().storage_path.resolve()
for extension in (".pdf", ".docx"):
candidate = (root / f"{file_id}{extension}").resolve()
if not candidate.is_relative_to(root):
raise ValueError("Le chemin sort du répertoire d'upload")
if candidate.is_file():
return candidate
raise FileNotFoundError(file_id)