Source code for kstlib.utils.secure_delete

"""Secure file deletion utilities."""

from __future__ import annotations

import logging
import os
import platform
import secrets
import shutil
import subprocess
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING

log = logging.getLogger(__name__)

DEFAULT_CHUNK_SIZE = 1024 * 1024


[docs] class SecureDeleteMethod(str, Enum): """Available strategies for securely deleting files.""" AUTO = "auto" COMMAND = "command" OVERWRITE = "overwrite"
[docs] @dataclass(slots=True) class SecureDeleteReport: """Summary result produced by :func:`secure_delete`.""" success: bool method: SecureDeleteMethod passes: int command: Sequence[str] | None = None message: str | None = None
if TYPE_CHECKING: from collections.abc import Sequence
[docs] def secure_delete( target: Path | str, *, passes: int = 3, method: SecureDeleteMethod | str = SecureDeleteMethod.AUTO, chunk_size: int = DEFAULT_CHUNK_SIZE, zero_last_pass: bool = True, ) -> SecureDeleteReport: """Securely remove ``target`` from disk. Args: target: File path that must be removed. passes: Number of overwrite passes to perform when relying on the built-in overwrite implementation. Values lower than ``1`` raise ``ValueError``. method: Preferred strategy. ``auto`` attempts to use a platform shred command and falls back to overwriting when none is available or when the command fails. ``command`` forces the usage of a system command and reports an error if it is not available. ``overwrite`` forces the Python overwrite implementation. chunk_size: Size, in bytes, of the chunks written during the overwrite loop. Defaults to 1 MiB. zero_last_pass: If ``True``, the final overwrite pass writes zeros instead of random data. Returns: A :class:`SecureDeleteReport` describing the outcome. Raises: ValueError: If ``passes`` is lower than ``1`` or if ``target`` does not reference a regular file. Example: Securely remove a cleartext file once it is no longer needed:: >>> from pathlib import Path >>> from kstlib.utils.secure_delete import secure_delete, SecureDeleteMethod >>> path = Path("secret.txt") >>> _ = path.write_text("classified") # doctest: +SKIP >>> report = secure_delete(path, method=SecureDeleteMethod.OVERWRITE, passes=1) # doctest: +SKIP >>> report.success # doctest: +SKIP True """ path = Path(target) if passes < 1: raise ValueError("passes must be >= 1") if not path.exists(): return SecureDeleteReport( success=True, method=SecureDeleteMethod(method), passes=passes, message="Target already removed.", ) if not path.is_file(): raise ValueError("secure_delete only supports regular files") resolved_method = SecureDeleteMethod(method) if resolved_method in {SecureDeleteMethod.AUTO, SecureDeleteMethod.COMMAND}: command = _build_platform_command(path, passes, zero_last_pass) if command is not None: result = subprocess.run( command, check=False, capture_output=True, text=True, ) if result.returncode == 0: return SecureDeleteReport( success=True, method=SecureDeleteMethod.COMMAND, passes=passes, command=command, ) if resolved_method == SecureDeleteMethod.COMMAND: message = result.stderr.strip() or result.stdout.strip() or "command failed" return SecureDeleteReport( success=False, method=SecureDeleteMethod.COMMAND, passes=passes, command=command, message=message, ) overwrite_report = _overwrite_and_remove(path, passes, chunk_size, zero_last_pass) if resolved_method == SecureDeleteMethod.COMMAND and not overwrite_report.success: overwrite_report.method = SecureDeleteMethod.COMMAND if overwrite_report.success: # Log the wipe success at DEBUG : the path is operator-provided # (file already targeted for destruction), counts only - never # the file content. Useful for audit trails of secret-scrubbing. log.debug( "[SECURE] Wiped %s (method=%s, passes=%d)", path.name, overwrite_report.method.value, overwrite_report.passes, ) return overwrite_report
def _build_platform_command(path: Path, passes: int, zero_last_pass: bool) -> list[str] | None: """Return a platform-specific secure delete command when available.""" system = platform.system().lower() shred_path = shutil.which("shred") if shred_path: command = [shred_path, "--force", "--remove"] if passes: command.append(f"--iterations={passes}") if zero_last_pass: command.append("--zero") command.append(str(path)) return command if system == "darwin": srm_path = shutil.which("srm") if srm_path: command = [srm_path, "-f"] if passes > 1: command.append("-m") if zero_last_pass: command.append("-z") command.append(str(path)) return command return None def _overwrite_and_remove( path: Path, passes: int, chunk_size: int, zero_last_pass: bool, ) -> SecureDeleteReport: """Overwrite ``path`` with random data before unlinking it.""" try: file_size = path.stat().st_size if file_size == 0: path.unlink(missing_ok=True) return SecureDeleteReport( success=True, method=SecureDeleteMethod.OVERWRITE, passes=passes, message="Zero-length file removed.", ) with path.open("r+b", buffering=0) as handle: for index in range(passes): handle.seek(0) remaining = file_size while remaining > 0: chunk = min(chunk_size, remaining) data = bytes(chunk) if index == passes - 1 and zero_last_pass else secrets.token_bytes(chunk) handle.write(data) remaining -= chunk handle.flush() os.fsync(handle.fileno()) path.unlink(missing_ok=True) return SecureDeleteReport( success=True, method=SecureDeleteMethod.OVERWRITE, passes=passes, message="File overwritten and removed.", ) except OSError as error: return SecureDeleteReport( success=False, method=SecureDeleteMethod.OVERWRITE, passes=passes, message=str(error), )