feat(12-02): normalize all four providers into CloudResourceAdapter contract

- GoogleDriveBackend: list_folder with full pagination, native doc size=None, get_capabilities
- OneDriveBackend: list_folder with @odata.nextLink pagination, get_capabilities
- WebDAVBackend: list_folder via PROPFIND with SSRF re-validation, get_capabilities
- NextcloudBackend: inherits CloudResourceAdapter from WebDAVBackend
- build_cloud_resource_adapter() added to factory
- 22 new contract/behavior/pagination/no-byte-download tests (51 total pass)
This commit is contained in:
curo1305
2026-06-18 22:45:09 +02:00
parent 71ba0293a5
commit ff33439f0a
5 changed files with 844 additions and 3 deletions
+22
View File
@@ -1,4 +1,7 @@
"""Factory for user-scoped cloud storage backends."""
from __future__ import annotations
from storage.cloud_base import CloudResourceAdapter
def build_cloud_backend(provider: str, credentials: dict):
@@ -31,3 +34,22 @@ def build_cloud_backend(provider: str, credentials: dict):
)
raise ValueError(f"Unknown provider: {provider}")
def build_cloud_resource_adapter(provider: str, credentials: dict) -> CloudResourceAdapter:
"""Build a CloudResourceAdapter for the given provider and credentials.
Returns a provider instance that implements the CloudResourceAdapter read-only
interface (list_folder, get_capabilities, merge_item_capabilities). All four
supported providers implement CloudResourceAdapter as a mixin alongside
their StorageBackend interface.
Raises:
ValueError: If provider is not one of the four supported cloud providers.
"""
backend = build_cloud_backend(provider, credentials)
if not isinstance(backend, CloudResourceAdapter):
raise ValueError(
f"Provider {provider!r} backend does not implement CloudResourceAdapter"
)
return backend
+178 -1
View File
@@ -27,6 +27,7 @@ import asyncio
import datetime
import io
import uuid
from typing import Optional
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
@@ -35,10 +36,41 @@ from googleapiclient.errors import HttpError
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
from storage.base import StorageBackend
from storage.cloud_base import (
ACTIONS,
REASON_INSUFFICIENT_SCOPE,
REASON_PROVIDER_UNSUPPORTED,
REASON_REAUTH_REQUIRED,
STATE_SUPPORTED,
STATE_TEMPORARILY_UNAVAILABLE,
STATE_UNSUPPORTED,
CloudCapability,
CloudListing,
CloudResource,
CloudResourceAdapter,
)
from storage.exceptions import CloudConnectionError # noqa: F401 re-exported for import compatibility
# Fields requested on every Drive files().list() call — explicit whitelist, no bytes
_DRIVE_LIST_FIELDS = (
"nextPageToken,"
"files(id,name,mimeType,size,modifiedTime,md5Checksum,parents,"
"capabilities(canEdit,canDelete,canRename,canMoveItemWithinDrive))"
)
class GoogleDriveBackend(StorageBackend):
_GOOGLE_NATIVE_MIMETYPES = frozenset({
"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",
"application/vnd.google-apps.map",
"application/vnd.google-apps.site",
"application/vnd.google-apps.script",
})
class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
"""Google Drive v3 implementation of StorageBackend.
Every sync googleapiclient call is wrapped in asyncio.to_thread() (Pitfall 7).
@@ -238,3 +270,148 @@ class GoogleDriveBackend(StorageBackend):
return await asyncio.to_thread(_check)
except Exception:
return False
# ── CloudResourceAdapter interface ────────────────────────────────────────
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 direct children of a Drive folder.
parent_ref=None browses the Drive root ('root').
Follows nextPageToken for complete pagination before returning.
Native Google document types (Docs, Sheets, etc.) are represented with
size=None — never treated as folders.
Never downloads file bytes or mutates provider content.
"""
folder_id = parent_ref if parent_ref else "root"
def _list_all() -> tuple[list[dict], bool]:
service = self._get_service()
items: list[dict] = []
next_token = page_token
complete = True
try:
while True:
params: dict = {
"q": f"'{folder_id}' in parents and trashed=false",
"fields": _DRIVE_LIST_FIELDS,
"pageSize": 200,
}
if next_token:
params["pageToken"] = next_token
resp = service.files().list(**params).execute()
items.extend(resp.get("files", []))
next_token = resp.get("nextPageToken")
if not next_token:
break
except HttpError as exc:
complete = False
if exc.resp.status in (401, 403):
pass # Will produce empty items with complete=False
return items, complete
try:
raw_items, complete = await asyncio.to_thread(_list_all)
except Exception:
return CloudListing(items=(), complete=False)
resources: list[CloudResource] = []
for item in raw_items:
mime = item.get("mimeType", "")
is_native = mime in _GOOGLE_NATIVE_MIMETYPES
is_folder = mime == "application/vnd.google-apps.folder"
kind = "folder" if is_folder else "file"
# Native docs have no binary size — represent as None
size: Optional[int] = None
if not is_native and not is_folder:
raw_size = item.get("size")
if raw_size is not None:
try:
size = int(raw_size)
except (TypeError, ValueError):
size = None
modified_str = item.get("modifiedTime")
modified_at: Optional[datetime.datetime] = None
if modified_str:
try:
modified_at = datetime.datetime.fromisoformat(
modified_str.replace("Z", "+00:00")
)
except ValueError:
pass
resources.append(
CloudResource(
id=uuid.uuid4(),
provider_item_id=item["id"],
connection_id=connection_id,
user_id=user_id,
name=item.get("name", ""),
kind=kind,
parent_ref=parent_ref,
content_type=mime if not is_folder else None,
size=size,
modified_at=modified_at,
etag=item.get("md5Checksum"),
)
)
return CloudListing(items=tuple(resources), complete=complete)
async def get_capabilities(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for Google Drive.
Uses drive.file scope — browse is supported; mutations pending Phase 13.
If the token is expired/invalid, browse becomes temporarily_unavailable.
Never creates, renames, moves, or deletes provider content.
"""
def _check_scope() -> bool:
"""Verify browse access with a minimal listing call."""
service = self._get_service()
service.files().list(pageSize=1, fields="files(id)").execute()
return True
try:
await asyncio.to_thread(_check_scope)
auth_ok = True
except HttpError as exc:
auth_ok = exc.resp.status not in (401, 403)
except Exception:
auth_ok = False
caps: dict[str, CloudCapability] = {}
for action in ACTIONS:
if action == "browse":
if auth_ok:
caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_TEMPORARILY_UNAVAILABLE,
reason=REASON_REAUTH_REQUIRED,
message="Re-authenticate to browse Google Drive.",
)
elif action in ("open", "preview"):
# Phase 13 mutations — not yet implemented
caps[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.",
)
else:
# upload, create_folder, rename, move, delete, change_tracking — Phase 13
caps[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.",
)
return caps
+141 -1
View File
@@ -26,19 +26,32 @@ import asyncio
import datetime
import io
import uuid
from typing import Optional
import httpx
import msal
from config import settings
from storage.base import StorageBackend
from storage.cloud_base import (
ACTIONS,
REASON_PROVIDER_UNSUPPORTED,
REASON_REAUTH_REQUIRED,
STATE_SUPPORTED,
STATE_TEMPORARILY_UNAVAILABLE,
STATE_UNSUPPORTED,
CloudCapability,
CloudListing,
CloudResource,
CloudResourceAdapter,
)
from storage.google_drive_backend import CloudConnectionError # reuse shared exception
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB — above Graph's 4 MB simple upload limit (Pitfall 6)
class OneDriveBackend(StorageBackend):
class OneDriveBackend(StorageBackend, CloudResourceAdapter):
"""Microsoft Graph / OneDrive implementation of StorageBackend.
Uses MSAL for token management and httpx for async HTTP to Microsoft Graph.
@@ -276,3 +289,130 @@ class OneDriveBackend(StorageBackend):
return r.is_success
except Exception:
return False
# ── CloudResourceAdapter interface ────────────────────────────────────────
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 direct children of a OneDrive folder.
parent_ref=None browses the drive root.
Follows @odata.nextLink for complete pagination.
Never downloads file bytes or mutates provider content.
"""
try:
await self._ensure_valid_token()
except CloudConnectionError:
return CloudListing(items=(), complete=False)
if parent_ref:
url = f"{GRAPH_BASE}/me/drive/items/{parent_ref}/children"
else:
url = f"{GRAPH_BASE}/me/drive/root/children"
# $select: id, name, folder/file facets, size, modified, eTag/cTag
select = "id,name,folder,file,size,lastModifiedDateTime,eTag,cTag,parentReference"
if page_token:
url = page_token # nextLink already embeds select/expand
resources: list[CloudResource] = []
complete = True
try:
async with httpx.AsyncClient() as client:
while url:
r = await client.get(
url,
headers=self._auth_headers(),
params={"$select": select} if not page_token else None,
timeout=30,
)
if not r.is_success:
complete = False
break
data = r.json()
for item in data.get("value", []):
is_folder = "folder" in item
kind = "folder" if is_folder else "file"
size: Optional[int] = item.get("size") if not is_folder else None
mod_str = item.get("lastModifiedDateTime")
modified_at: Optional[datetime.datetime] = None
if mod_str:
try:
modified_at = datetime.datetime.fromisoformat(
mod_str.replace("Z", "+00:00")
)
except ValueError:
pass
content_type: Optional[str] = None
if "file" in item:
content_type = item["file"].get("mimeType")
parent_ref_val = item.get("parentReference", {}).get("id")
resources.append(
CloudResource(
id=uuid.uuid4(),
provider_item_id=item["id"],
connection_id=connection_id,
user_id=user_id,
name=item.get("name", ""),
kind=kind,
parent_ref=parent_ref,
content_type=content_type,
size=size,
modified_at=modified_at,
etag=item.get("eTag") or item.get("cTag"),
)
)
url = data.get("@odata.nextLink")
except Exception:
complete = False
return CloudListing(items=tuple(resources), complete=complete)
async def get_capabilities(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for OneDrive.
Browse is supported when token is valid; mutations pending Phase 13.
Never creates, renames, moves, or deletes provider content.
"""
try:
await self._ensure_valid_token()
async with httpx.AsyncClient() as client:
r = await client.get(
f"{GRAPH_BASE}/me/drive",
params={"$select": "id"},
headers=self._auth_headers(),
timeout=10,
)
auth_ok = r.is_success
except Exception:
auth_ok = False
caps: dict[str, CloudCapability] = {}
for action in ACTIONS:
if action == "browse":
if auth_ok:
caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_TEMPORARILY_UNAVAILABLE,
reason=REASON_REAUTH_REQUIRED,
message="Re-authenticate to browse OneDrive.",
)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.",
)
return caps
+149 -1
View File
@@ -29,16 +29,31 @@ from __future__ import annotations
import asyncio
import io
import uuid
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from webdav3.client import Client
from storage.base import StorageBackend
from storage.cloud_base import (
ACTIONS,
REASON_OFFLINE,
REASON_PROVIDER_UNSUPPORTED,
STATE_SUPPORTED,
STATE_TEMPORARILY_UNAVAILABLE,
STATE_UNSUPPORTED,
CloudCapability,
CloudListing,
CloudResource,
CloudResourceAdapter,
)
from storage.cloud_utils import validate_cloud_url
class WebDAVBackend(StorageBackend):
class WebDAVBackend(StorageBackend, CloudResourceAdapter):
"""Generic WebDAV storage backend implementing all 7 StorageBackend abstract methods.
All synchronous webdavclient3 calls are wrapped in asyncio.to_thread() to avoid
@@ -220,3 +235,136 @@ class WebDAVBackend(StorageBackend):
return bool(result)
except Exception:
return False
# ── CloudResourceAdapter interface ────────────────────────────────────────
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 direct children of a WebDAV folder via non-mutating PROPFIND.
parent_ref=None lists the WebDAV root.
Uses client.list() + client.info() — no file byte download, no mutation.
SSRF guard is re-validated before every outbound request (D-17).
Absent properties treated as unknown (not unsupported).
"""
folder_path = parent_ref if parent_ref else ""
def _propfind() -> list[dict]:
validate_cloud_url(self._server_url)
items = self._client.list(folder_path)
folder_norm = folder_path.strip("/")
result: list[dict] = []
for name in items:
if not name or name in (".", "./"):
continue
if name.strip("/") == folder_norm:
continue
if folder_path:
item_path = f"{folder_path.rstrip('/')}/{name}"
else:
item_path = name
validate_cloud_url(self._server_url)
try:
info = self._client.info(item_path)
size_raw = info.get("size")
size = int(size_raw) if size_raw is not None else 0
is_dir = (
info.get("isdir", False)
or str(info.get("content_type", "")).startswith("httpd/unix-directory")
or name.endswith("/")
)
etag = info.get("etag") or info.get("getetag")
mod_str = info.get("modified") or info.get("getlastmodified")
content_type = info.get("content_type") or info.get("getcontenttype")
except Exception:
size = 0
is_dir = name.endswith("/")
etag = None
mod_str = None
content_type = None
result.append({
"path": item_path,
"name": name.rstrip("/"),
"is_dir": is_dir,
"size": size,
"etag": etag,
"modified": mod_str,
"content_type": content_type,
})
return result
try:
raw_items = await asyncio.to_thread(_propfind)
complete = True
except Exception:
return CloudListing(items=(), complete=False)
resources: list[CloudResource] = []
for item in raw_items:
kind = "folder" if item["is_dir"] else "file"
size: Optional[int] = None if item["is_dir"] else item["size"]
modified_at: Optional[datetime] = None
if item.get("modified"):
try:
modified_at = datetime.fromisoformat(item["modified"])
except (ValueError, TypeError):
pass
resources.append(
CloudResource(
id=uuid.uuid4(),
provider_item_id=item["path"],
connection_id=connection_id,
user_id=user_id,
name=item["name"],
kind=kind,
parent_ref=parent_ref,
content_type=item.get("content_type") if not item["is_dir"] else None,
size=size,
modified_at=modified_at,
etag=item.get("etag"),
)
)
return CloudListing(items=tuple(resources), complete=complete)
async def get_capabilities(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for WebDAV.
Uses non-mutating OPTIONS probe to verify server reachability.
WebDAV supports browse; mutations pending Phase 13.
Never creates, renames, moves, or deletes provider content.
"""
try:
validate_cloud_url(self._server_url)
reachable = await asyncio.to_thread(self._client.check, "/")
except Exception:
reachable = False
caps: dict[str, CloudCapability] = {}
for action in ACTIONS:
if action == "browse":
if reachable:
caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_TEMPORARILY_UNAVAILABLE,
reason=REASON_OFFLINE,
message="WebDAV server is unreachable.",
)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.",
)
return caps