""" Provider-neutral cloud resource capability and mutation contract for DocuVault. Defines the stable vocabulary (action keys, capability states, reason codes), immutable normalized value types (CloudCapability, CloudResource, CloudListing, mutation result types), and the abstract adapter interfaces: - CloudResourceAdapter — read-only browse/capability interface (Phase 12). - MutableCloudResourceAdapter — mutable-operation interface (Phase 13). Design decisions (12-CONTEXT.md D-06 through D-10, 13-CONTEXT.md D-01 through D-18): - Structurally unsupported actions remain visible but greyed out (D-06). - Capability discovery must never mutate provider content (D-08). - Permanent vs. temporary limitations have distinct visual states (D-09). - Mutable operations return typed result dicts — routers never parse raw provider error shapes (T-13-10). - Preview support is explicit and binary-only; Google Workspace and Office document preview/editing are excluded from Phase 13 (D-18). - Providers are never given permission to write audit rows or cloud_items directly — that is the service layer's responsibility (T-13-12). Credentials are never fields on any value type. """ from __future__ import annotations import uuid from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime from typing import Optional # ── Action keys ─────────────────────────────────────────────────────────────── ACTIONS = frozenset({ "browse", "open", "preview", "upload", "create_folder", "rename", "move", "delete", "change_tracking", }) # ── Capability states ───────────────────────────────────────────────────────── STATE_SUPPORTED = "supported" STATE_UNSUPPORTED = "unsupported" STATE_TEMPORARILY_UNAVAILABLE = "temporarily_unavailable" CAPABILITY_STATES = frozenset({ STATE_SUPPORTED, STATE_UNSUPPORTED, STATE_TEMPORARILY_UNAVAILABLE, }) # ── Stable reason codes ─────────────────────────────────────────────────────── REASON_PROVIDER_UNSUPPORTED = "provider_unsupported" REASON_INSUFFICIENT_SCOPE = "insufficient_scope" REASON_READ_ONLY = "read_only" REASON_REAUTH_REQUIRED = "reauth_required" REASON_OFFLINE = "offline" REASON_ITEM_RESTRICTED = "item_restricted" KNOWN_REASONS = frozenset({ REASON_PROVIDER_UNSUPPORTED, REASON_INSUFFICIENT_SCOPE, REASON_READ_ONLY, REASON_REAUTH_REQUIRED, REASON_OFFLINE, REASON_ITEM_RESTRICTED, }) # ── Normalized value types ──────────────────────────────────────────────────── @dataclass(frozen=True) class CloudCapability: """Normalized capability descriptor for a single action. state: one of the three CAPABILITY_STATES constants. reason: one of the KNOWN_REASONS codes when state != supported; None otherwise. message: controlled adapter output — safe for display; never raw provider text. Invariants enforced at construction: - state must be a known capability state. - reason and message are only present when state is not supported. - reason, when present, must be a known reason code. """ action: str state: str reason: Optional[str] = None message: Optional[str] = None def __post_init__(self) -> None: if self.state not in CAPABILITY_STATES: raise ValueError( f"Unknown capability state {self.state!r}; " f"must be one of {sorted(CAPABILITY_STATES)}" ) if self.action not in ACTIONS: raise ValueError( f"Unknown action {self.action!r}; must be one of {sorted(ACTIONS)}" ) if self.state == STATE_SUPPORTED: if self.reason is not None or self.message is not None: raise ValueError( "reason and message must be None when state is 'supported'" ) else: if self.reason is not None and self.reason not in KNOWN_REASONS: raise ValueError( f"Unknown reason code {self.reason!r}; " f"must be one of {sorted(KNOWN_REASONS)}" ) @dataclass(frozen=True) class CloudResource: """Normalized cloud item metadata — provider-independent representation. id: stable DocuVault UUID for this item; survives provider rename/move. provider_item_id: opaque provider-assigned identifier (e.g. Drive file ID). connection_id: UUID of the CloudConnection that owns this item. user_id: UUID of the owning user (security boundary). name: display name as reported by the provider. kind: "file" or "folder". parent_ref: opaque provider reference to the parent folder; None at root. content_type: MIME type for files; None for folders. size: provider-reported size in bytes; None if not reported or for folders. modified_at: provider-reported modification time; None if not available. etag: provider etag or version string for change detection; None if absent. capabilities: per-item capability overrides merged with connection defaults. """ id: uuid.UUID provider_item_id: str connection_id: uuid.UUID user_id: uuid.UUID name: str kind: str # "file" | "folder" parent_ref: Optional[str] = None content_type: Optional[str] = None size: Optional[int] = None modified_at: Optional[datetime] = None etag: Optional[str] = None capabilities: dict[str, CloudCapability] = field(default_factory=dict) def __post_init__(self) -> None: if self.kind not in ("file", "folder"): raise ValueError(f"kind must be 'file' or 'folder', got {self.kind!r}") for action, cap in self.capabilities.items(): if not isinstance(cap, CloudCapability): raise TypeError( f"capabilities[{action!r}] must be a CloudCapability instance" ) @dataclass(frozen=True) class CloudListing: """Result of listing a folder from a provider. items: normalized CloudResource children of the listed folder. complete: True when the listing represents the full authoritative contents (safe to mark unseen children deleted on reconciliation). False when the listing is partial, paginated, or failed — retained rows must NOT be marked deleted. next_page_token: opaque token for continuation; None when exhausted. """ items: tuple[CloudResource, ...] complete: bool = True next_page_token: Optional[str] = None def __post_init__(self) -> None: # Coerce list to tuple for immutability object.__setattr__(self, "items", tuple(self.items)) # ── Abstract adapter ────────────────────────────────────────────────────────── class CloudResourceAdapter(ABC): """Read-only cloud resource adapter contract for Phase 12. Phase 12 interface is intentionally limited to browse and capability discovery. No method here creates, renames, moves, or deletes provider content. Mutation methods are added in Phase 13 on a separate subclass interface. Credentials are passed at construction time (or via dependency injection) and are never exposed as fields or return values. """ @abstractmethod async def list_folder( self, connection_id: uuid.UUID, user_id: uuid.UUID, parent_ref: Optional[str] = None, page_token: Optional[str] = None, ) -> CloudListing: """List the direct children of a folder. parent_ref=None lists the connection root. Returns a CloudListing with complete=False on partial or failed fetches. Raises CloudConnectionError (from storage.exceptions) on auth failures. Never mutates provider content as a side effect. """ ... @abstractmethod async def get_capabilities( self, connection_id: uuid.UUID, user_id: uuid.UUID, ) -> dict[str, CloudCapability]: """Return connection-level capabilities for all defined ACTIONS. The returned dict must contain an entry for every action in ACTIONS. Probing must not create, rename, move, or delete any provider content. """ ... def merge_item_capabilities( self, connection_caps: dict[str, CloudCapability], item_caps: dict[str, CloudCapability], ) -> dict[str, CloudCapability]: """Merge per-item overrides onto connection defaults. Item capabilities take precedence over connection capabilities. Actions absent from item_caps fall back to connection_caps. The result always contains every action in ACTIONS. """ merged: dict[str, CloudCapability] = {} for action in ACTIONS: if action in item_caps: merged[action] = item_caps[action] elif action in connection_caps: merged[action] = connection_caps[action] else: # Safe default: unsupported with no reason merged[action] = CloudCapability( action=action, state=STATE_UNSUPPORTED, reason=REASON_PROVIDER_UNSUPPORTED, message="This action is not supported by the provider.", ) return merged # ── Mutable operation result kinds ──────────────────────────────────────────── # Stable kind vocabulary for mutation result dicts (T-13-10). # Routers and service layers must match these constants — never raw provider strings. MUT_KIND_FOLDER = "folder" # create_folder succeeded MUT_KIND_UPLOADED = "uploaded" # upload_file succeeded MUT_KIND_UPDATED = "updated" # rename / move succeeded MUT_KIND_DELETED = "deleted" # delete succeeded MUT_KIND_CONFLICT = "conflict" # name collision detected MUT_KIND_STALE = "stale" # etag mismatch / item changed externally MUT_KIND_OFFLINE = "offline" # provider temporarily unreachable MUT_KIND_REAUTH = "reauth_required" # credential failure (not transient) MUT_KIND_UNSUPPORTED = "unsupported" # provider does not support this operation MUT_KIND_INVALID_DEST = "invalid_destination" # bad move destination (SSRF / self) MUT_KIND_ERROR = "error" # unclassified provider error MUT_KINDS = frozenset({ MUT_KIND_FOLDER, MUT_KIND_UPLOADED, MUT_KIND_UPDATED, MUT_KIND_DELETED, MUT_KIND_CONFLICT, MUT_KIND_STALE, MUT_KIND_OFFLINE, MUT_KIND_REAUTH, MUT_KIND_UNSUPPORTED, MUT_KIND_INVALID_DEST, MUT_KIND_ERROR, }) # Stable reason codes for mutation results MUT_REASON_CREATED = "created" MUT_REASON_RENAMED = "renamed" MUT_REASON_MOVED = "moved" MUT_REASON_TRASHED = "trashed" MUT_REASON_PERMANENT = "permanent" MUT_REASON_NAME_COLLISION = "name_collision" MUT_REASON_ITEM_CHANGED = "item_changed" MUT_REASON_PROVIDER_OFFLINE = "provider_offline" MUT_REASON_TOKEN_EXPIRED = "token_expired" MUT_REASON_INVALID_GRANT = "invalid_grant" MUT_REASON_NOT_SUPPORTED = "not_supported" MUT_REASON_SSRF_BLOCKED = "ssrf_blocked" MUT_REASON_SELF_MOVE = "self_or_descendant_move" MUT_REASON_PROVIDER_ERROR = "provider_error" # ── Preview support declaration ─────────────────────────────────────────────── class PreviewSupport: """Explicit binary-only preview support declaration (D-18). Providers declare support here rather than inferring it from MIME quirks. Google Workspace and Microsoft Office document rendering/editing are explicitly excluded from Phase 13 preview scope. """ # MIME types with explicit binary preview support (D-18, Phase 13) SUPPORTED_BINARY_PREVIEW_TYPES: frozenset[str] = frozenset({ "application/pdf", "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "text/plain", "text/html", "text/css", "text/javascript", "application/json", "application/xml", "text/xml", "text/markdown", "text/csv", }) # MIME types explicitly excluded from Phase 13 preview (D-18) EXCLUDED_FROM_PREVIEW: frozenset[str] = frozenset({ # Google Workspace formats — require export, out of Phase 13 scope "application/vnd.google-apps.document", "application/vnd.google-apps.spreadsheet", "application/vnd.google-apps.presentation", "application/vnd.google-apps.form", "application/vnd.google-apps.drawing", # Microsoft Office formats — require Office rendering, out of Phase 13 scope "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", }) @classmethod def is_supported(cls, content_type: Optional[str]) -> bool: """Return True if the MIME type has explicit binary preview support. Returns False for excluded formats and unknown types. Phase 13 unsupported formats fall back to the authorized download path (D-02); they are never opened via raw provider URLs. """ if content_type is None: return False # Strip parameters (e.g. "text/plain; charset=utf-8" -> "text/plain") base_type = content_type.split(";")[0].strip().lower() if base_type in cls.EXCLUDED_FROM_PREVIEW: return False return base_type in cls.SUPPORTED_BINARY_PREVIEW_TYPES # ── Abstract mutable adapter ────────────────────────────────────────────────── class MutableCloudResourceAdapter(CloudResourceAdapter): """Phase 13 mutable-operation contract extending the read-only adapter. Defines the provider-neutral mutation interface for Phase 13: create_folder, rename, move, delete, and upload_file. Contract guarantees (T-13-10, T-13-11, T-13-12): - Every mutation method returns a normalized result dict with at minimum {'kind': , 'reason': }. Routers and service layers must never parse raw provider error classes — normalization happens inside the adapter. - Providers never write cloud_items or audit rows. Those responsibilities belong exclusively to the service layer (cloud_operations.py / cloud_items.py). - Refreshed credentials are returned upward via the result dict rather than stored by the provider (T-13-11). The service layer is the only place that persists encrypted credentials. """ @abstractmethod async def create_folder( self, parent_ref: Optional[str], name: str, connection_id: uuid.UUID, user_id: uuid.UUID, ) -> dict: """Create a folder in the provider and return a normalized result. Returns a dict with at minimum:: { 'kind': 'folder', 'reason': 'created', 'provider_item_id': str, # opaque new folder ID 'name': str, # actual name used (may differ on collision) 'parent_ref': str | None, } On name collision:: {'kind': 'conflict', 'reason': 'name_collision'} On transient provider failure:: {'kind': 'offline', 'reason': 'provider_offline'} On auth failure:: {'kind': 'reauth_required', 'reason': 'token_expired' | 'invalid_grant'} On unsupported operation:: {'kind': 'unsupported', 'reason': 'not_supported'} """ ... @abstractmethod async def rename( self, provider_item_id: str, new_name: str, etag: Optional[str], ) -> dict: """Rename a provider item and return a normalized result. etag is used for conditional write (D-07 stale detection). A None etag skips the precondition check. Returns a dict with at minimum:: { 'kind': 'updated', 'reason': 'renamed', 'provider_item_id': str, 'name': str, } On stale metadata (etag mismatch):: {'kind': 'stale', 'reason': 'item_changed'} On name collision:: {'kind': 'conflict', 'reason': 'name_collision'} """ ... @abstractmethod async def move( self, provider_item_id: str, destination_parent_ref: str, etag: Optional[str], ) -> dict: """Move a provider item to a new parent folder. destination_parent_ref must be on the same connection — cross-connection moves are rejected by the service layer, but providers should also reject obviously invalid destinations (SSRF guard for WebDAV, D-09). Returns a dict with at minimum:: { 'kind': 'updated', 'reason': 'moved', 'provider_item_id': str, 'destination_parent_ref': str, } On stale metadata (etag mismatch):: {'kind': 'stale', 'reason': 'item_changed'} On invalid destination (SSRF blocked or self-move):: {'kind': 'invalid_destination', 'reason': 'ssrf_blocked' | 'self_or_descendant_move'} """ ... @abstractmethod async def delete( self, provider_item_id: str, trash: bool = True, ) -> dict: """Delete a provider item (prefer trash/recycle-bin when supported, D-11). trash=True: use the provider's trash/recycle-bin when available. trash=False: permanently delete. Returns a dict with at minimum:: { 'kind': 'deleted', 'reason': 'trashed' | 'permanent', 'provider_item_id': str, } On auth failure:: {'kind': 'reauth_required', 'reason': 'token_expired' | 'invalid_grant'} """ ... @abstractmethod async def upload_file( self, parent_ref: Optional[str], filename: str, content: bytes, content_type: str, connection_id: uuid.UUID, user_id: uuid.UUID, ) -> dict: """Upload a file into the provider folder identified by parent_ref. content is raw bytes — never streamed out to provider URLs or logged. connection_id and user_id are passed for audit trail and reconciliation. Returns a dict with at minimum:: { 'kind': 'uploaded', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None, 'size': int, } On name collision:: {'kind': 'conflict', 'reason': 'name_collision', 'existing_name': str} On auth failure:: {'kind': 'reauth_required', 'reason': 'token_expired' | 'invalid_grant'} """ ... def _normalize_error( self, exc: Exception, *, default_kind: str = MUT_KIND_ERROR, default_reason: str = MUT_REASON_PROVIDER_ERROR, ) -> dict: """Normalize a provider exception into a controlled result dict. Provider-specific error classes must never escape this boundary. Subclasses should override to handle their own error types before calling super() for the fallback. Args: exc: The provider exception to normalize. default_kind: Fallback kind when the exception is unclassified. default_reason: Fallback reason when the exception is unclassified. Returns: A normalized result dict with at minimum {'kind': ..., 'reason': ...}. """ return { "kind": default_kind, "reason": default_reason, "message": "An unexpected provider error occurred.", }