Files
SpecterOps-Nemesis/projects/web_api/web_api/large_containers.py
Will Schroeder 6525ce7140 SeaweedFS (#107)
Replace Minio backend storage with SeaweedFS (as Minio has been deprecated).
2026-03-06 15:27:12 -08:00

1056 lines
44 KiB
Python

import json
import os
import re
import tempfile
import zipfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import psycopg
import pytsk3
from common.db import get_postgres_connection_str
from common.logger import get_logger
from common.models import File as FileModel
from common.models2.api import FileFilters
from common.queues import FILES_NEW_FILE_TOPIC, FILES_PUBSUB
from common.storage import StorageS3
from dapr.clients import DaprClient
from fastapi import HTTPException
logger = get_logger(__name__)
# Dapr state store name for container processing
DAPR_STORE_NAME = "container_processing"
MOUNTED_CONTAINER_PATH = os.getenv("MOUNTED_CONTAINER_PATH", "/mounted-containers/")
DEFAULT_EXPIRATION_DAYS = int(os.getenv("DEFAULT_EXPIRATION_DAYS", 100))
class ContainerStatus:
"""Container processing status enumeration"""
SUBMITTED = "submitted"
PROCESSING = "processing"
EXTRACTING = "extracting"
EXTRACTED = "extracted"
WORKFLOWS_COMPLETE = "workflows_complete"
FAILED = "failed"
class ContainerType:
"""Supported container types"""
ZIP = "zip"
DD_IMAGE = "dd_image"
class ContainerProgress:
"""In-memory progress tracking for container processing"""
def __init__(self):
self._progress: dict[str, dict[str, Any]] = {}
def initialize(self, container_id: str, total_files: int, total_bytes: int):
"""Initialize progress tracking for a container"""
self._progress[container_id] = {
"total_files": total_files,
"total_bytes": total_bytes,
"processed_files": 0,
"processed_bytes": 0,
"current_file": None,
"started_at": datetime.now(),
}
def update_file_progress(self, container_id: str, filename: str, file_size: int):
"""Update progress for a processed file"""
if container_id in self._progress:
progress = self._progress[container_id]
progress["processed_files"] += 1
progress["processed_bytes"] += file_size
progress["current_file"] = filename
def get_progress(self, container_id: str) -> dict[str, Any] | None:
"""Get current progress for a container"""
return self._progress.get(container_id)
def cleanup(self, container_id: str):
"""Clean up progress tracking for completed container"""
self._progress.pop(container_id, None)
def get_container_info(self, container_id: str) -> dict[str, Any] | None:
"""Get container info including originating container ID"""
return self._progress.get(container_id, {}).get("container_info")
def set_container_info(self, container_id: str, container_info: dict[str, Any]):
"""Set container info for tracking"""
if container_id in self._progress:
self._progress[container_id]["container_info"] = container_info
class BaseContainerExtractor:
"""Base class for container extractors"""
def __init__(self, storage: StorageS3, dapr_client: DaprClient, progress_tracker: ContainerProgress):
self.storage = storage
self.dapr_client = dapr_client
self.progress_tracker = progress_tracker
self.container_id: str | None = None
self.file_metadata: dict[str, Any] | None = None
self.file_filter = FilePathFilter()
self.filter_stats = {"files_processed": 0, "files_skipped_by_filter": 0, "files_skipped_by_error": 0}
def set_container_info(self, container_id: str, file_metadata: dict[str, Any]):
"""Set container processing information"""
self.container_id = container_id
self.file_metadata = file_metadata
# Handle timestamp - use current UTC time if not provided
if self.file_metadata.get("timestamp") is None:
current_utc = datetime.now(UTC)
self.file_metadata["timestamp"] = current_utc.isoformat()
# Handle expiration - use timestamp + DEFAULT_EXPIRATION_DAYS if not provided
if self.file_metadata.get("expiration") is None:
expiration_dt = datetime.now(UTC) + timedelta(days=DEFAULT_EXPIRATION_DAYS)
self.file_metadata["expiration"] = expiration_dt.isoformat()
# Initialize file filter if provided
filters = file_metadata.get("file_filters")
if filters:
if isinstance(filters, dict):
filters = FileFilters(**filters)
self.file_filter = FilePathFilter(filters)
logger.info(
"File filters configured for container",
container_id=container_id,
filter_stats=self.file_filter.get_filter_stats(),
)
else:
self.file_filter = FilePathFilter() # No filters
def should_process_file(self, file_path: str) -> bool:
"""Check if a file should be processed based on filters"""
should_include = self.file_filter.should_include_file(file_path)
if not should_include:
self.filter_stats["files_skipped_by_filter"] += 1
# logger.debug(
# "File skipped by filter",
# container_id=self.container_id,
# file_path=file_path
# )
# else:
# logger.debug(
# "File included by filter",
# container_id=self.container_id,
# file_path=file_path
# )
return should_include
def publish_file_message(self, temp_file_path: str, object_id: str, real_path: str):
"""Publish file message to the message bus"""
if self.file_metadata is None or self.container_id is None:
raise RuntimeError("container_id and file_metadata must be set via set_container_info before publishing")
file_message = FileModel(
object_id=object_id,
agent_id=self.file_metadata["agent_id"],
project=self.file_metadata["project"],
source=self.file_metadata["source"],
timestamp=self.file_metadata["timestamp"],
expiration=self.file_metadata["expiration"],
path=real_path,
originating_object_id=self.file_metadata.get("originating_object_id"),
nesting_level=(self.file_metadata.get("nesting_level", 0) + 1),
originating_container_id=self.container_id,
)
data = json.dumps(file_message.model_dump(exclude_unset=True, mode="json"))
self.dapr_client.publish_event(
pubsub_name=FILES_PUBSUB,
topic_name=FILES_NEW_FILE_TOPIC,
data=data,
data_content_type="application/json",
)
# Update progress and stats
file_size = os.path.getsize(temp_file_path)
self.progress_tracker.update_file_progress(self.container_id, os.path.basename(real_path), file_size)
self.filter_stats["files_processed"] += 1
logger.info(
"Published file message for extracted file",
container_id=self.container_id,
object_id=object_id,
path=real_path,
)
def get_processing_stats(self) -> dict:
"""Get processing statistics including filter stats"""
return {
**self.filter_stats,
"filter_config": self.file_filter.get_filter_stats() if self.file_filter else {"filters_enabled": False},
}
def extract_and_process(self, container_file_path: Path) -> int:
"""Extract container and process files sequentially. Returns number of files processed."""
raise NotImplementedError("Subclasses must implement extract_and_process")
def estimate_container_contents(self, container_file_path: Path) -> tuple[int, int]:
"""Estimate number of files and total size. Returns (file_count, total_size)."""
raise NotImplementedError("Subclasses must implement estimate_container_contents")
class FilePathFilter:
"""Handles file path filtering with glob and regex patterns"""
def __init__(self, filters: FileFilters | None = None):
self.filters = filters
self.compiled_regex_include_patterns = []
self.compiled_regex_exclude_patterns = []
if filters:
self._compile_regex_patterns()
def _compile_regex_patterns(self):
"""Pre-compile regex patterns for performance (only used when pattern_type is 'regex')"""
if not self.filters or self.filters.pattern_type != "regex":
return
if self.filters.include:
self.compiled_regex_include_patterns = [
re.compile(pattern, re.IGNORECASE) for pattern in self.filters.include
]
if self.filters.exclude:
self.compiled_regex_exclude_patterns = [
re.compile(pattern, re.IGNORECASE) for pattern in self.filters.exclude
]
def normalize_path(self, path: str) -> str:
"""Normalize path separators and format for consistent matching"""
# Convert backslashes to forward slashes
normalized = path.replace("\\", "/")
return normalized
def _matches_glob_pattern(self, normalized_path: str, pattern: str) -> bool:
"""Check if path matches a glob pattern using glob.fnmatch.filter"""
return Path(normalized_path).match(pattern)
def _matches_glob_patterns(self, normalized_path: str, patterns: list[str]) -> bool:
"""Check if path matches any glob pattern in the list"""
for pattern in patterns:
if self._matches_glob_pattern(normalized_path, pattern):
return True
return False
def _matches_regex_patterns(self, normalized_path: str, compiled_patterns: list[re.Pattern]) -> bool:
"""Check if path matches any compiled regex pattern"""
for pattern in compiled_patterns:
if pattern.search(normalized_path):
return True
return False
def should_include_file(self, file_path: str) -> bool:
"""
Determine if a file should be included based on include/exclude patterns.
Logic based on which patterns are provided:
1. No filters: Include everything
2. Only include patterns: Only include files matching include patterns (allowlist mode)
3. Only exclude patterns: Include everything except files matching exclude patterns (blocklist mode)
4. Both include and exclude: Include everything, apply excludes, then re-include matches from include patterns (exception mode)
This creates a natural hierarchy where include patterns act as exceptions to exclusions when both are present.
Args:
file_path: The file path to check
Returns:
bool: True if file should be included, False if it should be skipped
"""
if not self.filters:
return True
normalized_path = self.normalize_path(file_path)
include_patterns = self.filters.include or []
exclude_patterns = self.filters.exclude or []
has_include_patterns = len(include_patterns) > 0
has_exclude_patterns = len(exclude_patterns) > 0
# Check if file matches include patterns
include_match = False
if has_include_patterns:
if self.filters.pattern_type == "glob":
include_match = self._matches_glob_patterns(normalized_path, include_patterns)
else: # regex
include_match = self._matches_regex_patterns(normalized_path, self.compiled_regex_include_patterns)
# Check if file matches exclude patterns
exclude_match = False
if has_exclude_patterns:
if self.filters.pattern_type == "glob":
exclude_match = self._matches_glob_patterns(normalized_path, exclude_patterns)
else: # regex
exclude_match = self._matches_regex_patterns(normalized_path, self.compiled_regex_exclude_patterns)
# Apply logic based on which patterns are present
if has_include_patterns and has_exclude_patterns:
# Both present: include all, apply excludes, then re-include matches from include (exception mode)
if include_match:
return True # Include pattern acts as exception to exclude
else:
return not exclude_match # Normal exclude logic
elif has_include_patterns and not has_exclude_patterns:
# Only include patterns: allowlist mode
return include_match
elif has_exclude_patterns and not has_include_patterns:
# Only exclude patterns: blocklist mode
return not exclude_match
else:
# No patterns (shouldn't reach here due to early return, but for completeness)
return True
def get_filter_stats(self) -> dict:
"""Get statistics about the configured filters"""
if not self.filters:
return {"filters_enabled": False}
return {
"filters_enabled": True,
"pattern_type": self.filters.pattern_type,
"include_patterns_count": len(self.filters.include) if self.filters.include else 0,
"exclude_patterns_count": len(self.filters.exclude) if self.filters.exclude else 0,
"include_patterns": self.filters.include or [],
"exclude_patterns": self.filters.exclude or [],
"compiled_regex_include_count": len(self.compiled_regex_include_patterns),
"compiled_regex_exclude_count": len(self.compiled_regex_exclude_patterns),
}
class ZipContainerExtractor(BaseContainerExtractor):
"""ZIP file extractor that processes files sequentially with filtering"""
def estimate_container_contents(self, container_file_path: Path) -> tuple[int, int]:
"""Estimate ZIP contents with filtering"""
try:
with zipfile.ZipFile(container_file_path, "r") as zip_ref:
file_count = 0
total_size = 0
for info in zip_ref.infolist():
if info.is_dir():
continue
# Security checks (same as extraction)
if info.filename.startswith("/") or ".." in info.filename:
continue
if len(os.path.basename(info.filename)) > 255:
continue
# Apply file filters
if not self.should_process_file(info.filename):
continue
file_count += 1
total_size += info.file_size
return file_count, total_size
except Exception as e:
logger.warning(f"Error estimating ZIP contents: {e}")
return 0, 0
def extract_and_process(self, container_file_path: Path) -> int:
"""Extract ZIP file and process each file individually with filtering"""
if self.file_metadata is None:
raise RuntimeError("file_metadata must be set via set_container_info before extraction")
processed_count = 0
try:
with zipfile.ZipFile(container_file_path, "r") as zip_ref:
for info in zip_ref.infolist():
if info.is_dir():
continue
# Security checks
if info.filename.startswith("/") or ".." in info.filename:
logger.warning(f"Skipping unsafe path: {info.filename}")
self.filter_stats["files_skipped_by_error"] += 1
continue
if len(os.path.basename(info.filename)) > 255:
logger.warning(f"Skipping filename too long: {info.filename}")
self.filter_stats["files_skipped_by_error"] += 1
continue
# Apply file filters
if not self.should_process_file(info.filename):
continue # Skip this file due to filter
try:
# Extract single file to temporary location
with tempfile.NamedTemporaryFile(delete=False) as temp_extracted:
temp_extracted.write(zip_ref.read(info))
temp_extracted.flush()
# Upload to storage
object_id = self.storage.upload_file(temp_extracted.name)
# Calculate real path
base_dir = os.path.dirname(self.file_metadata["path"])
real_path = os.path.join(base_dir, info.filename).removeprefix(MOUNTED_CONTAINER_PATH)
# Publish file message
self.publish_file_message(temp_extracted.name, str(object_id), real_path)
processed_count += 1
except Exception as e:
logger.warning(f"Error processing file {info.filename}: {e}")
self.filter_stats["files_skipped_by_error"] += 1
finally:
# Clean up temporary file
if "temp_extracted" in locals() and os.path.exists(temp_extracted.name):
os.unlink(temp_extracted.name)
except Exception as e:
logger.exception(f"Error extracting ZIP file: {e}")
raise
# Log final statistics
stats = self.get_processing_stats()
logger.info("Container extraction completed", container_id=self.container_id, stats=stats)
return processed_count
class DDImageContainerExtractor(BaseContainerExtractor):
"""DD disk image extractor that processes files from filesystem structures"""
def estimate_container_contents(self, container_file_path: Path) -> tuple[int, int]:
"""Estimate DD image contents by parsing filesystem with filtering"""
try:
# Open the image file directly
img_info = pytsk3.Img_Info(str(container_file_path))
# Try to open the filesystem
try:
fs_info = pytsk3.FS_Info(img_info)
# Walk the filesystem to count files and calculate size
file_count = 0
total_size = 0
def walk_directory(directory, path="", stack=None):
"""Recursively walk directory to count files with filtering"""
nonlocal file_count, total_size
if stack is None:
stack = []
for entry in directory:
# Skip . and .. entries
if entry.info.name.name in [b".", b".."]:
continue
# Skip if entry doesn't have metadata
if not hasattr(entry.info.meta, "type"):
continue
try:
# Get the file name
filename = entry.info.name.name.decode("utf-8", errors="replace")
file_path = os.path.join(path, filename)
# If it's a directory, recurse
if entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
try:
sub_directory = entry.as_directory()
# Prevent infinite loops
inode = entry.info.meta.addr
if inode not in stack:
walk_directory(sub_directory, file_path, stack + [inode])
except Exception:
# Some directories might not be accessible
pass
# If it's a regular file, count it with filtering
elif entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_REG:
# Security checks (same as extraction)
if file_path.startswith("/") or ".." in file_path:
continue
if len(os.path.basename(filename)) > 255:
continue
# Apply file filters
if not self.should_process_file(file_path):
continue
# Skip empty files (same as extraction)
if entry.info.meta.size == 0:
continue
# logger.debug(f"file_path: {file_path}")
file_count += 1
if hasattr(entry.info.meta, "size"):
total_size += entry.info.meta.size
except Exception:
# Skip entries that can't be processed
continue
# Start walking from root
root_dir = fs_info.open_dir(path="/")
walk_directory(root_dir)
except Exception as e:
logger.warning(f"Could not parse filesystem, will return 0 estimates: {e}")
file_count = 0
total_size = 0
return file_count, total_size
except Exception as e:
logger.warning(f"Error estimating DD image contents: {e}")
return 0, 0
def extract_and_process(self, container_file_path: Path) -> int:
"""Extract DD image and process each file individually with filtering"""
if self.file_metadata is None:
raise RuntimeError("file_metadata must be set via set_container_info before extraction")
processed_count = 0
try:
# Open the image file directly
img_info = pytsk3.Img_Info(str(container_file_path))
try:
# Try to open the filesystem
fs_info = pytsk3.FS_Info(img_info)
# Process files from the filesystem
processed_count = self._process_filesystem(fs_info, img_info)
except Exception as e:
logger.exception(f"Could not parse filesystem: {e}")
except Exception as e:
logger.exception(f"Error extracting DD image: {e}")
raise
# Log final statistics
stats = self.get_processing_stats()
logger.info("Container extraction completed", container_id=self.container_id, stats=stats)
return processed_count
def _process_filesystem(self, fs_info: pytsk3.FS_Info, img_info: pytsk3.Img_Info) -> int:
"""Process files from a parsed filesystem"""
if self.file_metadata is None:
raise RuntimeError("file_metadata must be set via set_container_info before processing")
file_metadata = self.file_metadata
processed_count = 0
def process_directory(directory, path=""):
"""Recursively process files in directory"""
nonlocal processed_count
for entry in directory:
# Skip . and .. entries
if entry.info.name.name in [b".", b".."]:
continue
# Skip if entry doesn't have metadata
if not hasattr(entry.info.meta, "type"):
continue
try:
# Get the file name
filename = entry.info.name.name.decode("utf-8", errors="replace")
file_path = os.path.join(path, filename)
# If it's a directory, recurse
if entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_DIR:
try:
sub_directory = entry.as_directory()
process_directory(sub_directory, file_path)
except Exception as e:
logger.debug(f"Could not access directory {file_path}: {e}")
# If it's a regular file, process it
elif entry.info.meta.type == pytsk3.TSK_FS_META_TYPE_REG:
# Security checks
if file_path.startswith("/") or ".." in file_path:
logger.warning(f"Skipping unsafe path: {file_path}")
self.filter_stats["files_skipped_by_error"] += 1
continue
if len(os.path.basename(filename)) > 255:
logger.warning(f"Skipping filename too long: {file_path}")
self.filter_stats["files_skipped_by_error"] += 1
continue
# Apply file filters
if not self.should_process_file(file_path):
continue
# Skip empty files
if entry.info.meta.size == 0:
logger.debug(f"Skipping empty file: {file_path}")
continue
try:
# Read file content
file_content = entry.read_random(0, entry.info.meta.size)
# Save to temporary file and upload
with tempfile.NamedTemporaryFile(delete=False) as temp_extracted:
temp_extracted.write(file_content)
temp_extracted.flush()
# Upload to storage
object_id = self.storage.upload_file(temp_extracted.name)
# Calculate real path
base_dir = os.path.dirname(file_metadata["path"])
real_path = os.path.join(base_dir, file_path).removeprefix(MOUNTED_CONTAINER_PATH)
# Publish file message
self.publish_file_message(temp_extracted.name, str(object_id), real_path)
processed_count += 1
# Clean up temporary file
os.unlink(temp_extracted.name)
except Exception as e:
logger.warning(f"Error processing file {file_path}: {e}")
self.filter_stats["files_skipped_by_error"] += 1
except Exception as e:
logger.warning(f"Error processing entry: {e}")
self.filter_stats["files_skipped_by_error"] += 1
# Start processing from root
try:
root_dir = fs_info.open_dir(path="/")
process_directory(root_dir)
except Exception as e:
logger.error(f"Error opening root directory: {e}")
return processed_count
class LargeContainerProcessor:
"""Main processor for large container files"""
def __init__(self):
self.storage = StorageS3()
self.progress_tracker = ContainerProgress()
self.extractors = {
ContainerType.ZIP: ZipContainerExtractor,
ContainerType.DD_IMAGE: DDImageContainerExtractor,
}
# Get postgres connection string from Dapr secrets
self.postgres_connection_string = get_postgres_connection_str()
def create_container_record(
self,
container_id: str,
container_type: str,
file_metadata: dict[str, Any],
estimated_files: int,
estimated_size: int,
) -> None:
"""Create initial container processing record in database"""
try:
with psycopg.connect(self.postgres_connection_string) as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO container_processing (
container_id, container_type, original_filename, original_size,
agent_id, source, project, status, workflows_total,
processing_started_at, expiration
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
container_id,
container_type,
file_metadata.get("filename", os.path.basename(file_metadata.get("path", ""))),
file_metadata.get("size", 0),
file_metadata.get("agent_id"),
file_metadata.get("source"),
file_metadata.get("project"),
ContainerStatus.PROCESSING,
estimated_files,
datetime.now(),
file_metadata.get("expiration"),
),
)
conn.commit()
logger.info(
"Created container processing record", container_id=container_id, estimated_files=estimated_files
)
except Exception as e:
logger.error(f"Error creating container record: {e}", container_id=container_id)
raise
def update_container_status(self, container_id: str, status: str) -> None:
"""Update container status in database
Args:
container_id: Container UUID
status: New status value
"""
try:
with psycopg.connect(self.postgres_connection_string) as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE container_processing
SET status = %s
WHERE container_id = %s
""",
(status, container_id),
)
conn.commit()
logger.debug("Updated container status", container_id=container_id, status=status)
except Exception as e:
logger.error(f"Error updating container status: {e}", container_id=container_id)
def update_container_extraction_progress(
self, container_id: str, total_files_extracted: int, total_bytes_extracted: int
) -> None:
"""Update container extraction completion in database (called when extraction is done)
Args:
container_id: Container UUID
total_files_extracted: Final number of files extracted
total_bytes_extracted: Final number of bytes extracted
"""
try:
logger.info(
"Attempting to update container extraction progress in database",
container_id=container_id,
files_extracted=total_files_extracted,
bytes_extracted=total_bytes_extracted,
status=ContainerStatus.EXTRACTED,
)
with psycopg.connect(self.postgres_connection_string) as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE container_processing
SET total_files_extracted = %s,
total_bytes_extracted = %s,
status = %s
WHERE container_id = %s
""",
(total_files_extracted, total_bytes_extracted, ContainerStatus.EXTRACTED, container_id),
)
rows_affected = cur.rowcount
conn.commit()
logger.info(
"Successfully updated container extraction completion",
container_id=container_id,
files_extracted=total_files_extracted,
bytes_extracted=total_bytes_extracted,
rows_affected=rows_affected,
)
if rows_affected == 0:
logger.warning(
"No rows were updated - container_id may not exist in database", container_id=container_id
)
else:
# Verify the update by reading it back
with psycopg.connect(self.postgres_connection_string) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT status, total_files_extracted, total_bytes_extracted
FROM container_processing
WHERE container_id = %s
""",
(container_id,),
)
row = cur.fetchone()
if row:
logger.info(
"Verified database state after update",
container_id=container_id,
status=row[0],
total_files_extracted=row[1],
total_bytes_extracted=row[2],
)
else:
logger.error("Container not found in database after update", container_id=container_id)
except Exception as e:
logger.exception(
f"Error updating container extraction progress: {e}",
container_id=container_id,
files=total_files_extracted,
bytes=total_bytes_extracted,
)
def update_container_workflow_progress(
self, container_id: str, file_size: int = 0, increment_completed: bool = False, increment_failed: bool = False
) -> bool:
"""Update workflow completion progress and return True if all workflows are complete"""
try:
with psycopg.connect(self.postgres_connection_string) as conn:
with conn.cursor() as cur:
# Update counters and bytes processed (only for completed workflows)
if increment_completed:
cur.execute(
"""
UPDATE container_processing
SET workflows_completed = workflows_completed + 1,
total_bytes_processed = total_bytes_processed + %s
WHERE container_id = %s
""",
(file_size, container_id),
)
elif increment_failed:
cur.execute(
"""
UPDATE container_processing
SET workflows_failed = workflows_failed + 1
WHERE container_id = %s
""",
(container_id,),
)
# Check if all workflows are complete
cur.execute(
"""
SELECT workflows_completed, workflows_failed, workflows_total
FROM container_processing
WHERE container_id = %s
""",
(container_id,),
)
row = cur.fetchone()
if row:
completed, failed, total = row
all_complete = (completed + failed) >= total
if all_complete:
cur.execute(
"""
UPDATE container_processing
SET status = %s, processing_completed_at = %s
WHERE container_id = %s
""",
(ContainerStatus.WORKFLOWS_COMPLETE, datetime.now(), container_id),
)
logger.info(
"Container processing completed",
container_id=container_id,
completed=completed,
failed=failed,
total=total,
)
conn.commit()
return all_complete
conn.commit()
return False
except Exception as e:
logger.error(f"Error updating workflow progress: {e}", container_id=container_id)
return False
def get_container_status(self, container_id: str) -> dict[str, Any] | None:
"""Get container status from database"""
try:
with psycopg.connect(self.postgres_connection_string) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT container_id, container_type, status, total_files_extracted,
total_bytes_extracted, total_bytes_processed, workflows_completed,
workflows_failed, workflows_total, processing_started_at, processing_completed_at
FROM container_processing
WHERE container_id = %s
""",
(container_id,),
)
row = cur.fetchone()
if row:
return {
"container_id": row[0],
"container_type": row[1],
"status": row[2],
"total_files_extracted": row[3],
"total_bytes_extracted": row[4],
"total_bytes_processed": row[5],
"workflows_completed": row[6],
"workflows_failed": row[7],
"workflows_total": row[8],
"processing_started_at": row[9].isoformat() if row[9] else None,
"processing_completed_at": row[10].isoformat() if row[10] else None,
}
return None
except Exception as e:
logger.error(f"Error getting container status: {e}", container_id=container_id)
return None
def detect_container_type(self, filename: str, container_file_path: Path) -> str | None:
"""Detect container type from file path"""
filename_lower = filename.lower()
# Check ZIP files by extension first
if filename_lower.endswith(".zip"):
return ContainerType.ZIP
# Check disk images by extension - all formats supported by Sleuth Kit
disk_image_extensions = (
# Raw disk images
".dd",
".raw",
".img",
".image",
".bin",
".dmg",
# Forensic formats
".e01",
".ex01",
".l01",
".lx01",
".ewf",
".s01",
)
if any(filename_lower.endswith(ext) for ext in disk_image_extensions):
return ContainerType.DD_IMAGE
# For unknown extensions, check file content
try:
if zipfile.is_zipfile(container_file_path):
return ContainerType.ZIP
except Exception as e:
logger.warning(f"Error detecting container type: {e}")
return None
def process_container_from_path(self, container_id: str, file_path: Path, metadata: dict) -> dict[str, Any]:
"""Process a container file from a filesystem path."""
try:
# Detect container type
container_type = self.detect_container_type(metadata.get("filename", file_path.name), file_path)
if not container_type:
raise HTTPException(status_code=400, detail="Unsupported container type")
# Get appropriate extractor
# TODO: error if the container isn't detected properly?
extractor_class = self.extractors[container_type]
with DaprClient() as dapr_client:
extractor = extractor_class(self.storage, dapr_client, self.progress_tracker)
extractor.set_container_info(container_id, metadata)
# Estimate contents for progress tracking and create database record
file_count, total_size = extractor.estimate_container_contents(file_path)
logger.debug(f"Estimated container contents: {file_count} files, {total_size} bytes")
self.create_container_record(container_id, container_type, metadata, file_count, total_size)
# Initialize in-memory progress tracking
self.progress_tracker.initialize(container_id, file_count, total_size)
# Store container info for tracking
self.progress_tracker.set_container_info(container_id, {"container_id": container_id})
# Update status to EXTRACTING before starting extraction
self.update_container_status(container_id, ContainerStatus.EXTRACTING)
# Process the container
processed_files = extractor.extract_and_process(file_path)
# Update extraction completion in database with final counts
progress = self.progress_tracker.get_progress(container_id)
logger.info(
"Extraction complete, updating database",
container_id=container_id,
processed_files=processed_files,
progress_exists=progress is not None,
)
if progress:
processed_bytes = progress["processed_bytes"]
logger.info(
"Calling update_container_extraction_progress",
container_id=container_id,
processed_files=processed_files,
processed_bytes=processed_bytes,
)
self.update_container_extraction_progress(container_id, processed_files, processed_bytes)
else:
logger.error(
"Progress tracker returned None, cannot update extraction counts",
container_id=container_id,
processed_files=processed_files,
)
# Still try to update status to extracted even if we don't have byte counts
self.update_container_status(container_id, ContainerStatus.EXTRACTED)
return {
"container_id": container_id,
"container_type": container_type,
"processed_files": processed_files,
"estimated_files": file_count,
"estimated_size": total_size,
"status": ContainerStatus.EXTRACTED,
}
except Exception as e:
logger.exception(f"Error processing container {container_id}: {e}")
raise
def get_container_progress(self, container_id: str) -> dict[str, Any]:
"""Get processing progress for a container"""
progress = self.progress_tracker.get_progress(container_id)
if not progress:
return {"error": "Container not found or processing not started"}
total_files = progress["total_files"]
processed_files = progress["processed_files"]
total_bytes = progress["total_bytes"]
processed_bytes = progress["processed_bytes"]
file_progress = (processed_files / total_files * 100) if total_files > 0 else 0
byte_progress = (processed_bytes / total_bytes * 100) if total_bytes > 0 else 0
return {
"container_id": container_id,
"progress_percent_files": round(file_progress, 2),
"progress_percent_bytes": round(byte_progress, 2),
"processed_files": processed_files,
"total_files": total_files,
"processed_bytes": processed_bytes,
"total_bytes": total_bytes,
"current_file": progress.get("current_file"),
"started_at": progress["started_at"].isoformat(),
}