← Files VeraARCHIVED FILE

modules/studio-archive/scripts/google_drive.py

17.9 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""Bounded Google Drive access for Studio Archive and archive organization."""

from __future__ import annotations

import hashlib
import io
import json
import os
import re
import tempfile
from collections import Counter
from collections.abc import Mapping
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Protocol
from urllib.parse import quote

__all__ = [
    "DRIVE_FOLDER_MIME_TYPE",
    "DRIVE_SHORTCUT_MIME_TYPE",
    "DRIVE_SNAPSHOT_SCHEMA",
    "MAX_EVIDENCE_BYTES",
    "DriveError",
    "DriveGateway",
    "GoogleApiDriveGateway",
    "authorize_google_drive",
    "load_google_drive_gateway",
    "normalize_file_metadata",
    "snapshot_google_drive_folder",
]

DRIVE_FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
DRIVE_SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"
DRIVE_SNAPSHOT_SCHEMA = "vera.google_drive_folder_snapshot.v1"
DRIVE_SCOPE = "https://www.googleapis.com/auth/drive"
MAX_FILES = 5_000
MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
MAX_EVIDENCE_BYTES = 100 * 1024 * 1024
_ID_RE = re.compile(r"[A-Za-z0-9_-]{3,256}")
_CHECKSUM_RE = re.compile(r"[0-9a-f]+")
_FILE_FIELDS = (
    "id,name,mimeType,parents,driveId,size,modifiedTime,version,"
    "md5Checksum,sha256Checksum,trashed,"
    "capabilities(canEdit,canDownload,canMoveItemWithinDrive),"
    "shortcutDetails(targetId,targetMimeType)"
)


class DriveError(RuntimeError):
    """Raised when a bounded Drive operation cannot be completed safely."""


class DriveGateway(Protocol):
    """Small testable boundary around the Google Drive v3 API."""

    def get_file(self, file_id: str) -> dict[str, Any]:
        """Return the exact metadata fields used by the safety kernel."""

    def list_children(self, parent_id: str) -> list[dict[str, Any]]:
        """Return every non-trashed direct child of one folder."""

    def create_folder(self, parent_id: str, name: str) -> dict[str, Any]:
        """Create one folder below an already validated parent."""

    def move_file(
        self,
        file_id: str,
        *,
        old_parent_id: str,
        new_parent_id: str,
        new_name: str,
    ) -> dict[str, Any]:
        """Move and rename one file through a single Drive metadata update."""

    def download_bytes(self, file_id: str) -> bytes:
        """Download one bounded binary Drive file."""

    def export_bytes(self, file_id: str, mime_type: str) -> bytes:
        """Export one bounded Google-native document."""


def _drive_id(value: object, *, label: str) -> str:
    if not isinstance(value, str) or _ID_RE.fullmatch(value.strip()) is None:
        raise DriveError(f"{label} is not a valid Google Drive ID.")
    return value.strip()


def _text(value: object, *, label: str, maximum: int = 1_000) -> str:
    if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
        raise DriveError(f"{label} is invalid.")
    return value.strip()


def _canonical_sha256(value: Mapping[str, Any]) -> str:
    return hashlib.sha256(
        json.dumps(
            value,
            ensure_ascii=False,
            sort_keys=True,
            separators=(",", ":"),
        ).encode("utf-8")
    ).hexdigest()


def _path_component(name: str, file_id: str, duplicate: bool) -> str:
    encoded = quote(name, safe=" !#$&'()+,-.;=@[]^_`{}~")
    encoded = encoded or "unnamed"
    return f"{encoded}~{file_id[:12]}" if duplicate else encoded


def _optional_checksum(value: object, *, length: int) -> str | None:
    if value in {None, ""}:
        return None
    if (
        not isinstance(value, str)
        or len(value) != length
        or _CHECKSUM_RE.fullmatch(value) is None
    ):
        raise DriveError("Google Drive returned an invalid content checksum.")
    return value


def normalize_file_metadata(item: Mapping[str, Any], parent_id: str) -> dict[str, Any]:
    file_id = _drive_id(item.get("id"), label="Drive file ID")
    parents = item.get("parents")
    if not isinstance(parents, list) or parents != [parent_id]:
        raise DriveError("A Drive item does not have the expected single parent.")
    mime_type = _text(item.get("mimeType"), label="Drive MIME type", maximum=255)
    size_value = item.get("size")
    if size_value in {None, ""}:
        size_bytes: int | None = None
    else:
        try:
            size_bytes = int(size_value)
        except (TypeError, ValueError) as exc:
            raise DriveError("Google Drive returned an invalid file size.") from exc
        if size_bytes < 0:
            raise DriveError("Google Drive returned an invalid file size.")
    version = _text(item.get("version"), label="Drive version", maximum=40)
    if not version.isdigit():
        raise DriveError("Google Drive returned an invalid file version.")
    capabilities = item.get("capabilities") or {}
    if not isinstance(capabilities, Mapping):
        raise DriveError("Google Drive returned invalid capabilities.")
    drive_id = item.get("driveId")
    if drive_id is not None:
        drive_id = _drive_id(drive_id, label="Shared Drive ID")
    return {
        "file_id": file_id,
        "parent_id": parent_id,
        "name": _text(item.get("name"), label="Drive file name", maximum=768),
        "mime_type": mime_type,
        "size_bytes": size_bytes,
        "modified_time": _text(
            item.get("modifiedTime"), label="Drive modified time", maximum=80
        ),
        "version": version,
        "md5_checksum": _optional_checksum(item.get("md5Checksum"), length=32),
        "sha256_checksum": _optional_checksum(item.get("sha256Checksum"), length=64),
        "drive_id": drive_id,
        "capabilities": {
            "can_edit": capabilities.get("canEdit") is True,
            "can_download": capabilities.get("canDownload") is True,
            "can_move_within_drive": (
                capabilities.get("canMoveItemWithinDrive") is True
            ),
        },
    }


class GoogleApiDriveGateway:
    """Google API client implementation of the narrow Drive gateway."""

    def __init__(self, service: Any) -> None:
        self._service = service

    @staticmethod
    def _execute(request: Any, *, label: str) -> dict[str, Any]:
        try:
            from googleapiclient.errors import HttpError
        except ModuleNotFoundError as exc:
            raise DriveError("The Google Drive API client is unavailable.") from exc
        try:
            result = request.execute()
        except (HttpError, OSError, RuntimeError, ValueError) as exc:
            raise DriveError(f"{label}: {exc}") from exc
        if not isinstance(result, dict):
            raise DriveError(f"{label}: Google Drive returned malformed metadata.")
        return result

    def get_file(self, file_id: str) -> dict[str, Any]:
        request = self._service.files().get(
            fileId=_drive_id(file_id, label="Drive file ID"),
            fields=_FILE_FIELDS,
            supportsAllDrives=True,
        )
        return self._execute(request, label="Could not read Google Drive metadata")

    def list_children(self, parent_id: str) -> list[dict[str, Any]]:
        selected = _drive_id(parent_id, label="Drive parent ID")
        page_token: str | None = None
        children: list[dict[str, Any]] = []
        while True:
            request = self._service.files().list(
                q=f"'{selected}' in parents and trashed = false",
                spaces="drive",
                pageSize=1_000,
                pageToken=page_token,
                fields=f"nextPageToken,incompleteSearch,files({_FILE_FIELDS})",
                supportsAllDrives=True,
                includeItemsFromAllDrives=True,
            )
            response = self._execute(
                request, label="Could not list Google Drive folder"
            )
            if response.get("incompleteSearch") is True:
                raise DriveError("Google Drive folder search was incomplete.")
            page = response.get("files", [])
            if not isinstance(page, list) or not all(
                isinstance(item, dict) for item in page
            ):
                raise DriveError("Google Drive returned a malformed child list.")
            children.extend(page)
            page_token = response.get("nextPageToken")
            if page_token is None:
                return children
            if not isinstance(page_token, str) or not page_token:
                raise DriveError("Google Drive returned an invalid page token.")

    def create_folder(self, parent_id: str, name: str) -> dict[str, Any]:
        body = {
            "name": _text(name, label="Drive folder name", maximum=255),
            "mimeType": DRIVE_FOLDER_MIME_TYPE,
            "parents": [_drive_id(parent_id, label="Drive parent ID")],
        }
        request = self._service.files().create(
            body=body, fields=_FILE_FIELDS, supportsAllDrives=True
        )
        return self._execute(request, label="Could not create Google Drive folder")

    def move_file(
        self,
        file_id: str,
        *,
        old_parent_id: str,
        new_parent_id: str,
        new_name: str,
    ) -> dict[str, Any]:
        request = self._service.files().update(
            fileId=_drive_id(file_id, label="Drive file ID"),
            addParents=_drive_id(new_parent_id, label="new Drive parent ID"),
            removeParents=_drive_id(old_parent_id, label="old Drive parent ID"),
            body={"name": _text(new_name, label="Drive file name", maximum=768)},
            fields=_FILE_FIELDS,
            supportsAllDrives=True,
        )
        return self._execute(request, label="Could not move Google Drive file")

    @staticmethod
    def _download(request: Any) -> bytes:
        try:
            from googleapiclient.errors import HttpError
            from googleapiclient.http import MediaIoBaseDownload
        except ModuleNotFoundError as exc:
            raise DriveError("The Google Drive API client is unavailable.") from exc
        target = io.BytesIO()
        downloader = MediaIoBaseDownload(target, request)
        done = False
        try:
            while not done:
                _, done = downloader.next_chunk()
                if target.tell() > MAX_EVIDENCE_BYTES:
                    raise DriveError(
                        "Google Drive evidence exceeds the 100 MB read boundary."
                    )
        except DriveError:
            raise
        except (HttpError, OSError, RuntimeError, ValueError) as exc:
            raise DriveError(f"Could not read Google Drive content: {exc}") from exc
        return target.getvalue()

    def download_bytes(self, file_id: str) -> bytes:
        request = self._service.files().get_media(
            fileId=_drive_id(file_id, label="Drive file ID"),
            supportsAllDrives=True,
        )
        return self._download(request)

    def export_bytes(self, file_id: str, mime_type: str) -> bytes:
        request = self._service.files().export_media(
            fileId=_drive_id(file_id, label="Drive file ID"),
            mimeType=_text(mime_type, label="Drive export MIME type", maximum=255),
        )
        return self._download(request)


def _google_dependencies() -> tuple[Any, Any, Any, Any, Any]:
    try:
        import google.auth
        from google.auth.transport.requests import Request
        from google.oauth2.credentials import Credentials
        from google_auth_oauthlib.flow import InstalledAppFlow
        from googleapiclient.discovery import build
    except ModuleNotFoundError as exc:
        raise DriveError(
            "Google Drive dependencies are unavailable; install the declared "
            "Studio Archive requirements."
        ) from exc
    return google.auth, Request, Credentials, InstalledAppFlow, build


def authorize_google_drive(
    client_secrets_path: Path,
    token_path: Path,
) -> GoogleApiDriveGateway:
    """Run an explicit installed-app OAuth flow and store a private refresh token."""

    _, _, _, installed_app_flow, build = _google_dependencies()
    secrets_path = client_secrets_path.expanduser().resolve(strict=True)
    token = token_path.expanduser()
    token.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    try:
        flow = installed_app_flow.from_client_secrets_file(
            str(secrets_path), [DRIVE_SCOPE]
        )
        credentials = flow.run_local_server(port=0)
        descriptor, temporary_name = tempfile.mkstemp(
            prefix=f".{token.name}.", suffix=".tmp", dir=token.parent
        )
        temporary = Path(temporary_name)
        try:
            with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
                handle.write(credentials.to_json())
                handle.flush()
                os.fsync(handle.fileno())
            temporary.replace(token)
        finally:
            temporary.unlink(missing_ok=True)
        if os.name == "posix":
            token.chmod(0o600)
        service = build("drive", "v3", credentials=credentials, cache_discovery=False)
    except (OSError, RuntimeError, ValueError) as exc:
        raise DriveError(f"Google Drive authorization failed: {exc}") from exc
    return GoogleApiDriveGateway(service)


def load_google_drive_gateway(token_path: Path | None = None) -> GoogleApiDriveGateway:
    """Load a stored user token or Application Default Credentials."""

    google_auth, request_type, credentials_type, _, build = _google_dependencies()
    selected_token = token_path.expanduser() if token_path is not None else None
    try:
        if selected_token is not None and selected_token.is_file():
            credentials = credentials_type.from_authorized_user_file(
                str(selected_token), [DRIVE_SCOPE]
            )
        else:
            credentials, _ = google_auth.default(scopes=[DRIVE_SCOPE])
        if not credentials.valid:
            if not credentials.expired or not credentials.refresh_token:
                raise DriveError("Google Drive credentials require authorization.")
            credentials.refresh(request_type())
        service = build("drive", "v3", credentials=credentials, cache_discovery=False)
    except DriveError:
        raise
    except (OSError, RuntimeError, ValueError) as exc:
        raise DriveError(f"Google Drive credentials are unavailable: {exc}") from exc
    return GoogleApiDriveGateway(service)


def snapshot_google_drive_folder(
    gateway: DriveGateway,
    root_folder_id: str,
    client_id: str,
    engagement_id: str,
    *,
    max_files: int = MAX_FILES,
    max_total_bytes: int = MAX_TOTAL_BYTES,
) -> dict[str, Any]:
    """Return one immutable recursive metadata snapshot for a Drive client folder."""

    root_id = _drive_id(root_folder_id, label="Drive root folder ID")
    root = gateway.get_file(root_id)
    if root.get("mimeType") != DRIVE_FOLDER_MIME_TYPE or root.get("trashed") is True:
        raise DriveError("Selected Google Drive root is not an active folder.")
    root_name = _text(root.get("name"), label="Drive root folder name", maximum=768)
    drive_id = root.get("driveId")
    if drive_id is not None:
        drive_id = _drive_id(drive_id, label="Shared Drive ID")
    files: list[dict[str, Any]] = []
    excluded: list[dict[str, str]] = []
    total_bytes = 0
    folder_count = 1
    visited = {root_id}
    pending: list[tuple[str, PurePosixPath]] = [(root_id, PurePosixPath())]
    while pending:
        parent_id, parent_path = pending.pop()
        children = gateway.list_children(parent_id)
        names = Counter(str(item.get("name") or "") for item in children)
        for raw in sorted(
            children,
            key=lambda item: (
                str(item.get("name") or "").casefold(),
                str(item.get("name") or ""),
                str(item.get("id") or ""),
            ),
        ):
            normalized = normalize_file_metadata(raw, parent_id)
            component = _path_component(
                normalized["name"],
                normalized["file_id"],
                names[normalized["name"]] > 1,
            )
            relative_path = (parent_path / component).as_posix()
            if normalized["mime_type"] == DRIVE_FOLDER_MIME_TYPE:
                if normalized["file_id"] in visited:
                    excluded.append(
                        {"relative_path": relative_path, "reason": "folder_cycle"}
                    )
                    continue
                visited.add(normalized["file_id"])
                folder_count += 1
                pending.append((normalized["file_id"], parent_path / component))
                continue
            if normalized["mime_type"] == DRIVE_SHORTCUT_MIME_TYPE:
                excluded.append(
                    {"relative_path": relative_path, "reason": "drive_shortcut"}
                )
                continue
            if len(files) >= max_files:
                raise DriveError(
                    f"Google Drive folder exceeds the {max_files}-file boundary."
                )
            if normalized["size_bytes"] is not None:
                total_bytes += normalized["size_bytes"]
                if total_bytes > max_total_bytes:
                    raise DriveError(
                        "Google Drive folder exceeds the bounded known-byte total."
                    )
            files.append({"relative_path": relative_path, **normalized})
    files.sort(
        key=lambda item: (item["relative_path"].casefold(), item["relative_path"])
    )
    excluded.sort(
        key=lambda item: (item["relative_path"].casefold(), item["relative_path"])
    )
    content = {
        "schema_version": DRIVE_SNAPSHOT_SCHEMA,
        "client_id": _text(client_id, label="client ID", maximum=80),
        "engagement_id": _text(engagement_id, label="engagement ID", maximum=80),
        "root_folder_id": root_id,
        "root_name": root_name,
        "drive_id": drive_id,
        "captured_at": datetime.now(timezone.utc).isoformat(),
        "file_count": len(files),
        "folder_count": folder_count,
        "known_total_bytes": total_bytes,
        "files": files,
        "excluded": excluded,
    }
    return {**content, "content_sha256": _canonical_sha256(content)}

SHA-256: 7ea637902377f41bbc785f0d2224246bba69f7f5eb67526120ccbd9e7ed5e2e5