Files
gh-christianlouis-docuelevate/app/utils/filename_utils.py
T
google-labs-jules[bot] 9b748db4d4 refactor(tasks): extract filename regex to shared constant
Move the valid filename regex pattern to a shared constant in `app/utils/filename_utils.py` and update both the task logic and security tests to use it. This eliminates duplication and ensures consistency across the codebase.

Also normalized line endings to LF in affected files to ensure CI compatibility.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 14:23:31 +00:00

210 lines
7.9 KiB
Python

import logging
import os
import re
import uuid
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
# Pattern for valid filenames (alphanumeric, dash, underscore, period, and space)
# Used for validating GPT-provided filenames and other inputs
VALID_FILENAME_PATTERN = r"^[\w\-\. ]+$"
VALID_FILENAME_RE = re.compile(VALID_FILENAME_PATTERN)
def get_unique_filename(original_path: str, check_exists_func: Callable[[str], bool] | None = None) -> str:
"""
Generates a unique filename by appending a timestamp or counter when a collision occurs.
Args:
original_path (str): The original file path
check_exists_func (callable): Function that checks if file exists in target system.
Takes a path string and returns True if exists, False otherwise.
If None, will use local filesystem check.
Returns:
str: A unique filename that doesn't collide with existing files
"""
if check_exists_func is None:
check_exists_func = os.path.exists
path = Path(original_path)
directory = str(path.parent)
filename = path.name
name, ext = os.path.splitext(filename)
# If file doesn't exist, return the original
if not check_exists_func(original_path):
return original_path
# Try timestamp-based suffix first (more user-friendly)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_filename = f"{name}_{timestamp}{ext}"
new_path = os.path.join(directory, new_filename)
if not check_exists_func(new_path):
logger.info(f"Renamed '{filename}' to '{new_filename}' to avoid collision")
return new_path
# If timestamp-based name also exists, try random UUID
uuid_str = str(uuid.uuid4())[:8] # Use first 8 chars of UUID for brevity
new_filename = f"{name}_{uuid_str}{ext}"
new_path = os.path.join(directory, new_filename)
if not check_exists_func(new_path):
logger.info(f"Renamed '{filename}' to '{new_filename}' using UUID to avoid collision")
return new_path
# If that still exists (very unlikely), use incremental numbering
counter = 1
while counter < 1000: # Limit to avoid infinite loop
new_filename = f"{name}_{counter}{ext}"
new_path = os.path.join(directory, new_filename)
if not check_exists_func(new_path):
logger.info(f"Renamed '{filename}' to '{new_filename}' using counter to avoid collision")
return new_path
counter += 1
# If we got here, something is weird - just use a full UUID
new_filename = f"{name}_{str(uuid.uuid4())}{ext}"
new_path = os.path.join(directory, new_filename)
logger.warning(f"Had to use full UUID to rename '{filename}' to '{new_filename}'")
return new_path
def get_unique_filepath_with_counter(directory: str, base_filename: str, extension: str = ".pdf") -> str:
"""
Returns a unique filepath in the specified directory using a numeric counter suffix.
If 'base_filename.pdf' exists, it will append '-0001', '-0002', etc.
This function implements robust collision handling with zero-padded numeric suffixes
as required for document storage organization.
Args:
directory (str): Directory path where the file will be stored
base_filename (str): Base name for the file (without extension)
extension (str): File extension including the dot (default: ".pdf")
Returns:
str: Full path to a unique filename
Examples:
>>> get_unique_filepath_with_counter("/workdir/original", "2024-01-01_Invoice")
"/workdir/original/2024-01-01_Invoice.pdf" # If doesn't exist
>>> get_unique_filepath_with_counter("/workdir/original", "2024-01-01_Invoice")
"/workdir/original/2024-01-01_Invoice-0001.pdf" # If original exists
"""
# Try the base filename first
candidate = os.path.join(directory, base_filename + extension)
if not os.path.exists(candidate):
return candidate
# If base exists, try with counter suffix
counter = 1
while True:
# Use zero-padded 4-digit counter: -0001, -0002, etc.
suffix = f"-{counter:04d}"
candidate = os.path.join(directory, f"{base_filename}{suffix}{extension}")
if not os.path.exists(candidate):
return candidate
counter += 1
# Sanity check to prevent infinite loops (very unlikely to reach)
if counter > 9999:
# Fall back to timestamp + UUID if somehow we have 10000 collisions
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
uuid_str = str(uuid.uuid4())[:8]
candidate = os.path.join(directory, f"{base_filename}-{timestamp}-{uuid_str}{extension}")
logger.warning(
f"Exceeded 9999 file collisions for {base_filename}, "
f"using timestamp+UUID: {os.path.basename(candidate)}"
)
return candidate
def sanitize_filename(filename: str) -> str:
r"""
Sanitize a filename to ensure it's valid across different file systems
and prevent path traversal attacks.
Args:
filename (str): The filename to sanitize
Returns:
str: A sanitized filename
Security:
- Removes path separators (/ and \)
- Prevents path traversal patterns (..)
- Replaces problematic characters with underscores
- Ensures compatibility across Windows, Linux, and macOS
"""
# First, replace all path separators (both Unix and Windows style) with underscores
sanitized = filename.replace("/", "_").replace("\\", "_")
# Replace characters that are problematic in various filesystems
# Keep only alphanumeric, dash, underscore, period, and space
sanitized = re.sub(r"[^\w\-\. ]", "_", sanitized)
# Remove or replace path traversal patterns
# Replace specifically '..' to prevent path traversal while preserving single dots
sanitized = sanitized.replace("..", "_")
# Replace multiple spaces/underscores with single ones
sanitized = re.sub(r"__+", "_", sanitized)
sanitized = re.sub(r" +", " ", sanitized)
# Trim leading/trailing spaces, periods, and underscores which cause issues in Windows
sanitized = sanitized.strip(". _")
# Ensure the filename isn't empty after sanitization
if not sanitized or sanitized == ".":
sanitized = f"document_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
return sanitized
def extract_remote_path(file_path: str, base_dir: str, remote_base: str = "") -> str:
"""
Extract a remote path for a file by preserving its directory structure
relative to the base directory, but with a new remote base path.
Modified to skip 'processed' directory in the remote path.
"""
# Normalize paths for consistent handling across platforms
file_path = os.path.normpath(file_path)
base_dir = os.path.normpath(base_dir)
# Get relative path from base directory
if file_path.startswith(base_dir):
rel_path = os.path.relpath(file_path, base_dir)
else:
# If not a subdirectory of base_dir, just use the filename
rel_path = os.path.basename(file_path)
# Skip 'processed' directory if it's in the path
path_parts = rel_path.split(os.sep)
if "processed" in path_parts:
# Remove 'processed' from the path
path_parts.remove("processed")
rel_path = os.path.join(*path_parts)
# Combine with remote base path
if remote_base:
if remote_base.startswith("/"):
# Handle absolute path for services like Dropbox
remote_path = os.path.join(remote_base[1:], rel_path)
else:
remote_path = os.path.join(remote_base, rel_path)
else:
remote_path = rel_path
# Convert to forward slashes for compatibility with most cloud services
remote_path = remote_path.replace(os.sep, "/")
return remote_path