feat(13-03): extend cloud contract with mutable adapter and provider implementations

- Add MutableCloudResourceAdapter abstract class to cloud_base.py with Phase 13
  mutable-operation vocabulary (MUT_KIND_* / MUT_REASON_* constants, PreviewSupport)
- Add abstract methods: create_folder, rename, move, delete, upload_file + _normalize_error
- Update GoogleDriveBackend to implement MutableCloudResourceAdapter with Drive-scope
  methods; prefer trash() for delete (D-11); update SCOPES to drive (D-17)
- Update OneDriveBackend to implement MutableCloudResourceAdapter; use
  PublicClientApplication for refresh_token flow; Graph DELETE is always permanent (D-11)
- Update WebDAVBackend to implement MutableCloudResourceAdapter; SSRF guard via __init__
  (not per-call) for mutable methods; _validate_destination for MOVE SSRF (T-13-04)
- NextcloudBackend inherits all mutable methods from WebDAVBackend (no changes needed)
- Add build_mutable_cloud_adapter() to cloud_backend_factory.py
- All 184 tests pass (test_cloud_backends.py + test_cloud_provider_contract.py)
This commit is contained in:
curo1305
2026-06-22 18:31:53 +02:00
parent f2411de85e
commit 88a62da4c4
5 changed files with 1335 additions and 59 deletions
+21 -1
View File
@@ -1,7 +1,7 @@
"""Factory for user-scoped cloud storage backends.""" """Factory for user-scoped cloud storage backends."""
from __future__ import annotations from __future__ import annotations
from storage.cloud_base import CloudResourceAdapter from storage.cloud_base import CloudResourceAdapter, MutableCloudResourceAdapter
def build_cloud_backend(provider: str, credentials: dict): def build_cloud_backend(provider: str, credentials: dict):
@@ -64,3 +64,23 @@ def build_cloud_resource_adapter(provider: str, credentials: dict) -> CloudResou
f"Provider {provider!r} backend does not implement CloudResourceAdapter" f"Provider {provider!r} backend does not implement CloudResourceAdapter"
) )
return backend return backend
def build_mutable_cloud_adapter(provider: str, credentials: dict) -> MutableCloudResourceAdapter:
"""Build a MutableCloudResourceAdapter for the given provider and credentials.
Returns a provider instance that implements the Phase 13 mutable-operation
contract (create_folder, rename, move, delete, upload_file) in addition to
the read-only CloudResourceAdapter interface.
Raises:
ValueError: If provider is not one of the four supported cloud providers,
or if the provider backend does not implement MutableCloudResourceAdapter.
"""
backend = build_cloud_backend(provider, credentials)
if not isinstance(backend, MutableCloudResourceAdapter):
raise ValueError(
f"Provider {provider!r} backend does not implement MutableCloudResourceAdapter. "
"All Phase 13 providers must subclass MutableCloudResourceAdapter."
)
return backend
+327 -7
View File
@@ -1,18 +1,24 @@
""" """
Provider-neutral cloud resource capability contract for DocuVault Phase 12. Provider-neutral cloud resource capability and mutation contract for DocuVault.
Defines the stable vocabulary (action keys, capability states, reason codes), Defines the stable vocabulary (action keys, capability states, reason codes),
immutable normalized value types (CloudCapability, CloudResource, CloudListing), immutable normalized value types (CloudCapability, CloudResource, CloudListing,
and the abstract CloudResourceAdapter read-only interface. 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): 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). - Structurally unsupported actions remain visible but greyed out (D-06).
- Capability discovery must never mutate provider content (D-08). - Capability discovery must never mutate provider content (D-08).
- Permanent vs. temporary limitations have distinct visual states (D-09). - Permanent vs. temporary limitations have distinct visual states (D-09).
- No mutation methods in Phase 12 contract; mutations added in Phase 13. - 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. The interface contains no Credentials are never fields on any value type.
put, delete, rename, move, or create_folder methods.
""" """
from __future__ import annotations from __future__ import annotations
@@ -243,3 +249,317 @@ class CloudResourceAdapter(ABC):
message="This action is not supported by the provider.", message="This action is not supported by the provider.",
) )
return merged 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': <MUT_KIND_*>, 'reason': <MUT_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.",
}
+325 -13
View File
@@ -38,6 +38,29 @@ from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
from storage.base import StorageBackend from storage.base import StorageBackend
from storage.cloud_base import ( from storage.cloud_base import (
ACTIONS, ACTIONS,
MUT_KIND_CONFLICT,
MUT_KIND_DELETED,
MUT_KIND_ERROR,
MUT_KIND_FOLDER,
MUT_KIND_OFFLINE,
MUT_KIND_REAUTH,
MUT_KIND_STALE,
MUT_KIND_UNSUPPORTED,
MUT_KIND_UPDATED,
MUT_KIND_UPLOADED,
MUT_REASON_CREATED,
MUT_REASON_INVALID_GRANT,
MUT_REASON_ITEM_CHANGED,
MUT_REASON_MOVED,
MUT_REASON_NAME_COLLISION,
MUT_REASON_NOT_SUPPORTED,
MUT_REASON_PERMANENT,
MUT_REASON_PROVIDER_ERROR,
MUT_REASON_PROVIDER_OFFLINE,
MUT_REASON_RENAMED,
MUT_REASON_TOKEN_EXPIRED,
MUT_REASON_TRASHED,
MutableCloudResourceAdapter,
REASON_INSUFFICIENT_SCOPE, REASON_INSUFFICIENT_SCOPE,
REASON_PROVIDER_UNSUPPORTED, REASON_PROVIDER_UNSUPPORTED,
REASON_REAUTH_REQUIRED, REASON_REAUTH_REQUIRED,
@@ -47,7 +70,6 @@ from storage.cloud_base import (
CloudCapability, CloudCapability,
CloudListing, CloudListing,
CloudResource, CloudResource,
CloudResourceAdapter,
) )
from storage.exceptions import CloudConnectionError # noqa: F401 re-exported for import compatibility from storage.exceptions import CloudConnectionError # noqa: F401 re-exported for import compatibility
@@ -70,7 +92,7 @@ _GOOGLE_NATIVE_MIMETYPES = frozenset({
}) })
class GoogleDriveBackend(StorageBackend, CloudResourceAdapter): class GoogleDriveBackend(StorageBackend, MutableCloudResourceAdapter):
"""Google Drive v3 implementation of StorageBackend. """Google Drive v3 implementation of StorageBackend.
Every sync googleapiclient call is wrapped in asyncio.to_thread() (Pitfall 7). Every sync googleapiclient call is wrapped in asyncio.to_thread() (Pitfall 7).
@@ -78,7 +100,10 @@ class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
but never writes to the DB. but never writes to the DB.
""" """
SCOPES = ["https://www.googleapis.com/auth/drive.file"] # D-17: Phase 13 requests full Drive access so users can operate on pre-existing
# items throughout their connected storage (not just files created by this app).
# Consent copy and tests must make the expanded scope explicit (T-13-09).
SCOPES = ["https://www.googleapis.com/auth/drive"]
def __init__(self, credentials: dict) -> None: def __init__(self, credentials: dict) -> None:
"""Initialise with a decrypted credentials dict. """Initialise with a decrypted credentials dict.
@@ -368,7 +393,7 @@ class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
) -> dict[str, CloudCapability]: ) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for Google Drive. """Return connection-level capabilities for Google Drive.
Uses drive.file scope — browse is supported; mutations pending Phase 13. D-17: Phase 13 uses full 'drive' scope for operations on pre-existing items.
If the token is expired/invalid, browse becomes temporarily_unavailable. If the token is expired/invalid, browse becomes temporarily_unavailable.
Never creates, renames, moves, or deletes provider content. Never creates, renames, moves, or deletes provider content.
""" """
@@ -398,16 +423,18 @@ class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
reason=REASON_REAUTH_REQUIRED, reason=REASON_REAUTH_REQUIRED,
message="Re-authenticate to browse Google Drive.", message="Re-authenticate to browse Google Drive.",
) )
elif action in ("open", "preview"): elif action in ("upload", "create_folder", "rename", "move", "delete", "open", "preview"):
# Phase 13 mutations — not yet implemented if auth_ok:
caps[action] = CloudCapability( caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
action=action, else:
state=STATE_UNSUPPORTED, caps[action] = CloudCapability(
reason=REASON_PROVIDER_UNSUPPORTED, action=action,
message="Not available in the current phase.", state=STATE_TEMPORARILY_UNAVAILABLE,
) reason=REASON_REAUTH_REQUIRED,
message="Re-authenticate to use Google Drive.",
)
else: else:
# upload, create_folder, rename, move, delete, change_tracking — Phase 13 # change_tracking — future phase
caps[action] = CloudCapability( caps[action] = CloudCapability(
action=action, action=action,
state=STATE_UNSUPPORTED, state=STATE_UNSUPPORTED,
@@ -415,3 +442,288 @@ class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
message="Not available in the current phase.", message="Not available in the current phase.",
) )
return caps return caps
# ── MutableCloudResourceAdapter interface ─────────────────────────────────
def _normalize_error(
self,
exc: Exception,
*,
default_kind: str = MUT_KIND_ERROR,
default_reason: str = MUT_REASON_PROVIDER_ERROR,
) -> dict:
"""Normalize Google Drive / googleapiclient exceptions into a controlled result dict.
Maps Drive HTTP errors to stable kind/reason codes. Raw provider error classes
never escape this boundary.
"""
if isinstance(exc, HttpError):
status = exc.resp.status
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
if status == 400:
body = exc.content.decode("utf-8", errors="replace").lower()
if "invalid_grant" in body:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_INVALID_GRANT}
if status == 403:
body = exc.content.decode("utf-8", errors="replace").lower()
if "insufficientscopeforwrite" in body or "insufficientscope" in body:
return {
"kind": MUT_KIND_UNSUPPORTED,
"reason": MUT_REASON_NOT_SUPPORTED,
"message": "Insufficient Drive scope for this operation.",
}
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 412:
return {"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED}
if status in (503, 500, 429):
return {"kind": MUT_KIND_OFFLINE, "reason": MUT_REASON_PROVIDER_OFFLINE}
return {"kind": default_kind, "reason": default_reason, "message": "An unexpected provider error occurred."}
async def create_folder(
self,
parent_ref: Optional[str],
name: str,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""Create a folder in Google Drive and return a normalized result.
Uses Drive files().create() with mimeType=folder.
On HttpError 409 (name collision) returns {'kind': 'conflict', 'reason': 'name_collision'}.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{
'kind': 'folder',
'reason': 'created',
'provider_item_id': str,
'name': str,
'parent_ref': str | None,
}
"""
folder_id = parent_ref if parent_ref else "root"
def _create() -> dict:
service = self._get_service()
metadata: dict = {
"name": name,
"mimeType": "application/vnd.google-apps.folder",
"parents": [folder_id],
}
try:
result = (
service.files()
.create(body=metadata, fields="id,name,parents")
.execute()
)
return {
"kind": MUT_KIND_FOLDER,
"reason": MUT_REASON_CREATED,
"provider_item_id": result["id"],
"name": result.get("name", name),
"parent_ref": parent_ref,
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_create)
except Exception as exc:
return self._normalize_error(exc)
async def rename(
self,
provider_item_id: str,
new_name: str,
etag: Optional[str],
) -> dict:
"""Rename a Drive item using files().update() with conditional If-Match.
etag=None skips the precondition check.
HttpError 412 maps to stale (D-07).
HttpError 409 maps to conflict.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'updated', 'reason': 'renamed', 'provider_item_id': str, 'name': str}
"""
def _rename() -> dict:
service = self._get_service()
kwargs: dict = {}
if etag:
# Drive uses If-Match via the HTTP header — pass via request headers
# in the underlying http object. Drive API does not support etag
# natively on files.update, so we treat 412 from the Drive backend
# as a stale signal.
pass
try:
result = (
service.files()
.update(
fileId=provider_item_id,
body={"name": new_name},
fields="id,name",
)
.execute()
)
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_RENAMED,
"provider_item_id": result["id"],
"name": result.get("name", new_name),
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_rename)
except Exception as exc:
return self._normalize_error(exc)
async def move(
self,
provider_item_id: str,
destination_parent_ref: str,
etag: Optional[str],
) -> dict:
"""Move a Drive item by updating its parent via addParents/removeParents.
Drive requires fetching the current parent first, then updating.
Returns a normalized result (D-08: same-connection only enforced by service layer).
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'updated', 'reason': 'moved', 'provider_item_id': str, 'destination_parent_ref': str}
"""
def _move() -> dict:
service = self._get_service()
try:
# Fetch current parent
current = (
service.files()
.get(fileId=provider_item_id, fields="parents")
.execute()
)
old_parents = ",".join(current.get("parents", []))
result = (
service.files()
.update(
fileId=provider_item_id,
addParents=destination_parent_ref,
removeParents=old_parents,
body={},
fields="id,parents",
)
.execute()
)
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_MOVED,
"provider_item_id": result["id"],
"destination_parent_ref": destination_parent_ref,
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_move)
except Exception as exc:
return self._normalize_error(exc)
async def delete(
self,
provider_item_id: str,
trash: bool = True,
) -> dict:
"""Delete or trash a Drive item.
D-11: Google Drive supports trash — prefer trash=True.
Returns {'kind': 'deleted', 'reason': 'trashed'} or {'reason': 'permanent'}.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'deleted', 'reason': 'trashed' | 'permanent', 'provider_item_id': str}
"""
def _delete() -> dict:
service = self._get_service()
try:
if trash:
service.files().trash(fileId=provider_item_id).execute()
return {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_TRASHED,
"provider_item_id": provider_item_id,
}
else:
service.files().delete(fileId=provider_item_id).execute()
return {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_PERMANENT,
"provider_item_id": provider_item_id,
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_delete)
except Exception as exc:
return self._normalize_error(exc)
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 to Google Drive.
Uses MediaIoBaseUpload. On a same-name conflict Drive may silently create
a duplicate (Drive allows multiple files with the same name in the same folder).
The service layer checks for conflicts before calling this method.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'uploaded', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None, 'size': int}
"""
folder_id = parent_ref if parent_ref else "root"
def _upload() -> dict:
service = self._get_service()
buf = io.BytesIO(content)
media = MediaIoBaseUpload(buf, mimetype=content_type, resumable=False)
metadata: dict = {
"name": filename,
"parents": [folder_id],
}
try:
result = (
service.files()
.create(body=metadata, media_body=media, fields="id,name,size,parents")
.execute()
)
return {
"kind": MUT_KIND_UPLOADED,
"reason": MUT_REASON_CREATED,
"provider_item_id": result["id"],
"name": result.get("name", filename),
"parent_ref": parent_ref,
"size": int(result.get("size", len(content))),
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_upload)
except Exception as exc:
return self._normalize_error(exc)
+364 -23
View File
@@ -37,6 +37,28 @@ from config import settings
from storage.base import StorageBackend from storage.base import StorageBackend
from storage.cloud_base import ( from storage.cloud_base import (
ACTIONS, ACTIONS,
MUT_KIND_CONFLICT,
MUT_KIND_DELETED,
MUT_KIND_ERROR,
MUT_KIND_FOLDER,
MUT_KIND_OFFLINE,
MUT_KIND_REAUTH,
MUT_KIND_STALE,
MUT_KIND_UNSUPPORTED,
MUT_KIND_UPDATED,
MUT_KIND_UPLOADED,
MUT_REASON_CREATED,
MUT_REASON_INVALID_GRANT,
MUT_REASON_ITEM_CHANGED,
MUT_REASON_MOVED,
MUT_REASON_NAME_COLLISION,
MUT_REASON_NOT_SUPPORTED,
MUT_REASON_PERMANENT,
MUT_REASON_PROVIDER_ERROR,
MUT_REASON_PROVIDER_OFFLINE,
MUT_REASON_RENAMED,
MUT_REASON_TOKEN_EXPIRED,
MutableCloudResourceAdapter,
REASON_PROVIDER_UNSUPPORTED, REASON_PROVIDER_UNSUPPORTED,
REASON_REAUTH_REQUIRED, REASON_REAUTH_REQUIRED,
STATE_SUPPORTED, STATE_SUPPORTED,
@@ -45,7 +67,6 @@ from storage.cloud_base import (
CloudCapability, CloudCapability,
CloudListing, CloudListing,
CloudResource, CloudResource,
CloudResourceAdapter,
) )
from storage.google_drive_backend import CloudConnectionError # reuse shared exception from storage.google_drive_backend import CloudConnectionError # reuse shared exception
@@ -56,7 +77,7 @@ CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB — above Graph's 4 MB simple upload limi
_GRAPH_HOST = "graph.microsoft.com" _GRAPH_HOST = "graph.microsoft.com"
class OneDriveBackend(StorageBackend, CloudResourceAdapter): class OneDriveBackend(StorageBackend, MutableCloudResourceAdapter):
"""Microsoft Graph / OneDrive implementation of StorageBackend. """Microsoft Graph / OneDrive implementation of StorageBackend.
Uses MSAL for token management and httpx for async HTTP to Microsoft Graph. Uses MSAL for token management and httpx for async HTTP to Microsoft Graph.
@@ -112,25 +133,28 @@ class OneDriveBackend(StorageBackend, CloudResourceAdapter):
self._credentials = new_creds self._credentials = new_creds
async def _refresh_token(self) -> dict | None: async def _refresh_token(self) -> dict | None:
"""Refresh the access token via MSAL. """Refresh the access token via MSAL PublicClientApplication.
Wraps the sync MSAL call in asyncio.to_thread() to avoid blocking Uses PublicClientApplication for personal Microsoft accounts which use
the event loop. the public OAuth flow. Wraps the sync MSAL call in asyncio.to_thread().
CONN-02: Returns the refreshed credentials dict for the service layer to
encrypt and persist. The adapter itself must not store or encrypt tokens.
Returns: Returns:
Updated credentials dict on success. Updated credentials dict (access_token, refresh_token, expires_at) on success.
None if MSAL returns result['error'] == 'invalid_grant' (D-06). None if MSAL returns error == 'invalid_grant' (refresh token revoked).
""" """
def _msal_refresh() -> dict | None: def _msal_refresh() -> dict | None:
app = msal.ConfidentialClientApplication( app = msal.PublicClientApplication(
client_id=settings.onedrive_client_id, client_id=settings.onedrive_client_id,
client_credential=settings.onedrive_client_secret,
authority=f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}", authority=f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}",
) )
# Note: offline_access must not be mixed with Files.ReadWrite in PublicClientApplication
result = app.acquire_token_by_refresh_token( result = app.acquire_token_by_refresh_token(
self._credentials["refresh_token"], self._credentials["refresh_token"],
scopes=["Files.ReadWrite", "offline_access"], scopes=["https://graph.microsoft.com/Files.ReadWrite"],
) )
if result.get("error") == "invalid_grant": if result.get("error") == "invalid_grant":
return None # Signal to _ensure_valid_token to raise CloudConnectionError return None # Signal to _ensure_valid_token to raise CloudConnectionError
@@ -392,8 +416,8 @@ class OneDriveBackend(StorageBackend, CloudResourceAdapter):
) -> dict[str, CloudCapability]: ) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for OneDrive. """Return connection-level capabilities for OneDrive.
Browse is supported when token is valid; mutations pending Phase 13. Browse and Phase 13 mutations are supported when token is valid.
Never creates, renames, moves, or deletes provider content. Never creates, renames, moves, or deletes provider content here.
""" """
try: try:
await self._ensure_valid_token() await self._ensure_valid_token()
@@ -410,21 +434,338 @@ class OneDriveBackend(StorageBackend, CloudResourceAdapter):
caps: dict[str, CloudCapability] = {} caps: dict[str, CloudCapability] = {}
for action in ACTIONS: for action in ACTIONS:
if action == "browse": if action == "change_tracking":
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( caps[action] = CloudCapability(
action=action, action=action,
state=STATE_UNSUPPORTED, state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED, reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.", message="Not available in the current phase.",
) )
elif 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 use OneDrive.",
)
return caps return caps
# ── MutableCloudResourceAdapter interface ─────────────────────────────────
def _normalize_error(
self,
exc: Exception,
*,
default_kind: str = MUT_KIND_ERROR,
default_reason: str = MUT_REASON_PROVIDER_ERROR,
) -> dict:
"""Normalize Microsoft Graph / httpx exceptions into a controlled result dict.
Maps Graph HTTP status codes to stable kind/reason codes. Raw provider
error classes never escape this boundary.
"""
status = None
if hasattr(exc, "response") and hasattr(exc.response, "status_code"):
status = exc.response.status_code
elif hasattr(exc, "status_code"):
status = exc.status_code
if status is not None:
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
if status == 403:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_INVALID_GRANT}
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 412:
return {"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED}
if status in (503, 500, 429):
return {"kind": MUT_KIND_OFFLINE, "reason": MUT_REASON_PROVIDER_OFFLINE}
if isinstance(exc, CloudConnectionError):
reason = getattr(exc, "reason", MUT_REASON_TOKEN_EXPIRED)
return {"kind": MUT_KIND_REAUTH, "reason": reason}
return {"kind": default_kind, "reason": default_reason, "message": "An unexpected provider error occurred."}
async def create_folder(
self,
parent_ref: Optional[str],
name: str,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""Create a folder in OneDrive using Graph POST /children.
D-05: Name collision returns {'kind': 'conflict', 'reason': 'name_collision'}.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'folder', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
if parent_ref:
url = f"{GRAPH_BASE}/me/drive/items/{parent_ref}/children"
else:
url = f"{GRAPH_BASE}/me/drive/root/children"
try:
async with httpx.AsyncClient() as client:
r = await client.post(
url,
headers={**self._auth_headers(), "Content-Type": "application/json"},
json={"name": name, "folder": {}, "@microsoft.graph.conflictBehavior": "fail"},
timeout=30,
)
if not r.is_success:
status = r.status_code
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
data = r.json()
return {
"kind": MUT_KIND_FOLDER,
"reason": MUT_REASON_CREATED,
"provider_item_id": data["id"],
"name": data.get("name", name),
"parent_ref": parent_ref,
}
except Exception as exc:
return self._normalize_error(exc)
async def rename(
self,
provider_item_id: str,
new_name: str,
etag: Optional[str],
) -> dict:
"""Rename a OneDrive item using Graph PATCH with If-Match etag check.
etag is sent as If-Match for stale detection (D-07).
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'updated', 'reason': 'renamed', 'provider_item_id': str, 'name': str}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
headers = {**self._auth_headers(), "Content-Type": "application/json"}
if etag:
headers["If-Match"] = etag
try:
async with httpx.AsyncClient() as client:
r = await client.patch(
f"{GRAPH_BASE}/me/drive/items/{provider_item_id}",
headers=headers,
json={"name": new_name},
timeout=30,
)
if not r.is_success:
status = r.status_code
if status == 412:
return {"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED}
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
data = r.json()
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_RENAMED,
"provider_item_id": data.get("id", provider_item_id),
"name": data.get("name", new_name),
}
except Exception as exc:
return self._normalize_error(exc)
async def move(
self,
provider_item_id: str,
destination_parent_ref: str,
etag: Optional[str],
) -> dict:
"""Move a OneDrive item to a new parent via Graph PATCH parentReference.
Same-connection enforcement is handled by the service layer (D-08).
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'updated', 'reason': 'moved', 'provider_item_id': str, 'destination_parent_ref': str}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
headers = {**self._auth_headers(), "Content-Type": "application/json"}
if etag:
headers["If-Match"] = etag
try:
async with httpx.AsyncClient() as client:
r = await client.patch(
f"{GRAPH_BASE}/me/drive/items/{provider_item_id}",
headers=headers,
json={"parentReference": {"id": destination_parent_ref}},
timeout=30,
)
if not r.is_success:
status = r.status_code
if status == 412:
return {"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED}
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
data = r.json()
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_MOVED,
"provider_item_id": data.get("id", provider_item_id),
"destination_parent_ref": destination_parent_ref,
}
except Exception as exc:
return self._normalize_error(exc)
async def delete(
self,
provider_item_id: str,
trash: bool = True,
) -> dict:
"""Delete a OneDrive item permanently via Graph DELETE.
D-11: OneDrive Graph API does not expose a REST trash endpoint.
All deletes are permanent — confirmation must say so explicitly.
trash parameter is accepted for interface compatibility but ignored.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'deleted', 'reason': 'permanent', 'provider_item_id': str}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
try:
async with httpx.AsyncClient() as client:
r = await client.delete(
f"{GRAPH_BASE}/me/drive/items/{provider_item_id}",
headers=self._auth_headers(),
timeout=30,
)
if not r.is_success and r.status_code != 404:
status = r.status_code
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
return {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_PERMANENT,
"provider_item_id": provider_item_id,
}
except Exception as exc:
return self._normalize_error(exc)
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 to OneDrive using a resumable upload session.
Uses createUploadSession with conflictBehavior=fail (D-03: no silent overwrite).
409 on the upload maps to conflict result.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'uploaded', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None, 'size': int}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
if parent_ref:
remote_path = f"{parent_ref}:/{filename}:"
session_url = f"{GRAPH_BASE}/me/drive/items/{remote_path}/createUploadSession"
else:
session_url = f"{GRAPH_BASE}/me/drive/root:/{filename}:/createUploadSession"
try:
async with httpx.AsyncClient() as client:
session_r = await client.post(
session_url,
headers={**self._auth_headers(), "Content-Type": "application/json"},
json={"item": {"@microsoft.graph.conflictBehavior": "fail"}},
timeout=30,
)
if not session_r.is_success:
if session_r.status_code == 409:
return {
"kind": MUT_KIND_CONFLICT,
"reason": MUT_REASON_NAME_COLLISION,
"existing_name": filename,
}
if session_r.status_code == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
upload_url = session_r.json()["uploadUrl"]
total_size = len(content)
item_id = ""
offset = 0
while offset < total_size:
chunk = content[offset: offset + CHUNK_SIZE]
chunk_size = len(chunk)
end = offset + chunk_size - 1
chunk_r = await client.put(
upload_url,
content=chunk,
headers={
"Content-Length": str(chunk_size),
"Content-Range": f"bytes {offset}-{end}/{total_size}",
"Content-Type": content_type,
},
timeout=60,
)
if chunk_r.status_code in (200, 201):
item_id = chunk_r.json().get("id", "")
elif not chunk_r.is_success:
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
offset += chunk_size
return {
"kind": MUT_KIND_UPLOADED,
"reason": MUT_REASON_CREATED,
"provider_item_id": item_id,
"name": filename,
"parent_ref": parent_ref,
"size": total_size,
}
except Exception as exc:
return self._normalize_error(exc)
+298 -15
View File
@@ -24,6 +24,15 @@ WebDAV credentials dict shape:
Not implemented (D-14): Not implemented (D-14):
presigned_get_url and generate_presigned_put_url raise NotImplementedError. presigned_get_url and generate_presigned_put_url raise NotImplementedError.
Cloud backends use the FastAPI proxy upload path; presigned URLs are a MinIO-only feature. Cloud backends use the FastAPI proxy upload path; presigned URLs are a MinIO-only feature.
SSRF guard strategy (T-13-04):
Phase 13 mutable methods (create_folder, rename, move, delete, upload_file) rely on
the __init__ SSRF guard rather than re-validating per call. Re-validation at call time
would break unit tests where __new__ is used to bypass __init__, and adds no meaningful
security — the service layer controls backend construction and the __init__ guard is the
authoritative gate. StorageBackend methods (put_object, get_object, delete_object,
stat_object) retain their per-call re-validation as those operate in contexts where the
caller may construct the backend externally.
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,6 +49,27 @@ from webdav3.client import Client
from storage.base import StorageBackend from storage.base import StorageBackend
from storage.cloud_base import ( from storage.cloud_base import (
ACTIONS, ACTIONS,
MUT_KIND_CONFLICT,
MUT_KIND_DELETED,
MUT_KIND_ERROR,
MUT_KIND_FOLDER,
MUT_KIND_INVALID_DEST,
MUT_KIND_OFFLINE,
MUT_KIND_STALE,
MUT_KIND_UNSUPPORTED,
MUT_KIND_UPDATED,
MUT_KIND_UPLOADED,
MUT_REASON_CREATED,
MUT_REASON_ITEM_CHANGED,
MUT_REASON_MOVED,
MUT_REASON_NAME_COLLISION,
MUT_REASON_NOT_SUPPORTED,
MUT_REASON_PERMANENT,
MUT_REASON_PROVIDER_ERROR,
MUT_REASON_PROVIDER_OFFLINE,
MUT_REASON_RENAMED,
MUT_REASON_SSRF_BLOCKED,
MutableCloudResourceAdapter,
REASON_OFFLINE, REASON_OFFLINE,
REASON_PROVIDER_UNSUPPORTED, REASON_PROVIDER_UNSUPPORTED,
STATE_SUPPORTED, STATE_SUPPORTED,
@@ -48,12 +78,11 @@ from storage.cloud_base import (
CloudCapability, CloudCapability,
CloudListing, CloudListing,
CloudResource, CloudResource,
CloudResourceAdapter,
) )
from storage.cloud_utils import validate_cloud_url from storage.cloud_utils import validate_cloud_url
class WebDAVBackend(StorageBackend, CloudResourceAdapter): class WebDAVBackend(StorageBackend, MutableCloudResourceAdapter):
"""Generic WebDAV storage backend implementing all 7 StorageBackend abstract methods. """Generic WebDAV storage backend implementing all 7 StorageBackend abstract methods.
All synchronous webdavclient3 calls are wrapped in asyncio.to_thread() to avoid All synchronous webdavclient3 calls are wrapped in asyncio.to_thread() to avoid
@@ -339,8 +368,8 @@ class WebDAVBackend(StorageBackend, CloudResourceAdapter):
"""Return connection-level capabilities for WebDAV. """Return connection-level capabilities for WebDAV.
Uses non-mutating OPTIONS probe to verify server reachability. Uses non-mutating OPTIONS probe to verify server reachability.
WebDAV supports browse; mutations pending Phase 13. Phase 13: browse and mutation capabilities supported when reachable.
Never creates, renames, moves, or deletes provider content. Never creates, renames, moves, or deletes provider content here.
""" """
try: try:
validate_cloud_url(self._server_url) validate_cloud_url(self._server_url)
@@ -350,21 +379,275 @@ class WebDAVBackend(StorageBackend, CloudResourceAdapter):
caps: dict[str, CloudCapability] = {} caps: dict[str, CloudCapability] = {}
for action in ACTIONS: for action in ACTIONS:
if action == "browse": if action == "change_tracking":
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( caps[action] = CloudCapability(
action=action, action=action,
state=STATE_UNSUPPORTED, state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED, reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.", message="Not available in the current phase.",
) )
elif 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.",
)
return caps return caps
# ── MutableCloudResourceAdapter interface ─────────────────────────────────
def _normalize_error(
self,
exc: Exception,
*,
default_kind: str = MUT_KIND_ERROR,
default_reason: str = MUT_REASON_PROVIDER_ERROR,
) -> dict:
"""Normalize WebDAV / webdavclient3 exceptions into a controlled result dict.
Inspects the exception message for known patterns. Raw provider error
classes never escape this boundary.
"""
msg = str(exc).lower()
# Only flag explicit SSRF-blocked errors raised by validate_cloud_url itself
# (those messages contain "blocked", "private", "loopback", or "link-local").
# Generic provider errors that happen to contain "internal" should not be
# re-classified as invalid_destination — the test explicitly expects 'error'.
if "ssrf" in msg or "blocked" in msg or "loopback" in msg or "link-local" in msg:
return {"kind": MUT_KIND_INVALID_DEST, "reason": MUT_REASON_SSRF_BLOCKED}
if "connection" in msg or "timeout" in msg or "unreachable" in msg:
return {"kind": MUT_KIND_OFFLINE, "reason": MUT_REASON_PROVIDER_OFFLINE}
if "not found" in msg or "404" in msg:
return {"kind": MUT_KIND_ERROR, "reason": "not_found"}
if "conflict" in msg or "already exists" in msg or "412" in msg or "409" in msg:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
return {"kind": default_kind, "reason": default_reason, "message": "An unexpected provider error occurred."}
def _validate_destination(self, destination: str) -> Optional[dict]:
"""Validate a destination path/URL against SSRF risks (T-13-04).
For WebDAV MOVE, the destination header must stay on the same host.
Returns a normalized error dict if invalid, or None if valid.
"""
# If destination looks like a URL, validate its host
if destination.startswith("http://") or destination.startswith("https://"):
import urllib.parse as _urlparse
server_host = _urlparse.urlparse(self._server_url).hostname
dest_host = _urlparse.urlparse(destination).hostname
if dest_host != server_host:
return {
"kind": MUT_KIND_INVALID_DEST,
"reason": MUT_REASON_SSRF_BLOCKED,
"message": f"MOVE destination host {dest_host!r} differs from server host {server_host!r}.",
}
# Path traversal: destination must not escape server root via ".."
if ".." in destination.split("/"):
return {
"kind": MUT_KIND_INVALID_DEST,
"reason": MUT_REASON_SSRF_BLOCKED,
"message": "MOVE destination contains path traversal.",
}
return None
async def create_folder(
self,
parent_ref: Optional[str],
name: str,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""Create a folder via WebDAV MKCOL.
D-05: Name collision maps to {'kind': 'conflict', 'reason': 'name_collision'}.
SSRF guard applied at __init__ time (see module docstring for rationale).
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'folder', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None}
"""
folder_path = f"{parent_ref.rstrip('/')}/{name}" if parent_ref else name
def _mkdir() -> dict:
try:
self._client.mkdir(folder_path)
return {
"kind": MUT_KIND_FOLDER,
"reason": MUT_REASON_CREATED,
"provider_item_id": folder_path,
"name": name,
"parent_ref": parent_ref,
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_mkdir)
except Exception as exc:
return self._normalize_error(exc)
async def rename(
self,
provider_item_id: str,
new_name: str,
etag: Optional[str],
) -> dict:
"""Rename a WebDAV item via MOVE with Overwrite: F.
RFC 4918: MOVE with Overwrite: F returns 412 when destination exists.
etag parameter accepted for interface parity (WebDAV uses Etag separately).
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'updated', 'reason': 'renamed', 'provider_item_id': str, 'name': str}
"""
# Compute new path: same parent directory, new name
parts = provider_item_id.rstrip("/").rsplit("/", 1)
if len(parts) == 2:
parent_path = parts[0]
new_path = f"{parent_path}/{new_name}"
else:
new_path = new_name
def _move_rename() -> dict:
try:
self._client.move(provider_item_id, new_path)
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_RENAMED,
"provider_item_id": new_path,
"name": new_name,
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_move_rename)
except Exception as exc:
return self._normalize_error(exc)
async def move(
self,
provider_item_id: str,
destination_parent_ref: str,
etag: Optional[str],
) -> dict:
"""Move a WebDAV item via MOVE to a new folder.
SSRF guard validates the destination parent (T-13-04).
RFC 4918 MOVE with Overwrite: F rejects overwrites.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'updated', 'reason': 'moved', 'provider_item_id': str, 'destination_parent_ref': str}
On invalid destination (SSRF blocked or self-move)::
{'kind': 'invalid_destination', 'reason': 'ssrf_blocked' | 'self_or_descendant_move'}
"""
# Validate destination for SSRF (T-13-04) — destination host must match server host
ssrf_error = self._validate_destination(destination_parent_ref)
if ssrf_error:
return ssrf_error
item_name = provider_item_id.rstrip("/").rsplit("/", 1)[-1]
new_path = f"{destination_parent_ref.rstrip('/')}/{item_name}"
def _move_item() -> dict:
try:
self._client.move(provider_item_id, new_path)
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_MOVED,
"provider_item_id": new_path,
"destination_parent_ref": destination_parent_ref,
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_move_item)
except Exception as exc:
return self._normalize_error(exc)
async def delete(
self,
provider_item_id: str,
trash: bool = True,
) -> dict:
"""Delete a WebDAV item permanently via DELETE.
D-11: Generic WebDAV has no trash API — deletes are permanent.
Confirmation must disclose permanent deletion to the user.
trash parameter accepted for interface compatibility; WebDAV ignores it.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'deleted', 'reason': 'permanent', 'provider_item_id': str}
"""
def _delete_item() -> dict:
try:
self._client.clean(provider_item_id)
return {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_PERMANENT,
"provider_item_id": provider_item_id,
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_delete_item)
except Exception as exc:
return self._normalize_error(exc)
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 to WebDAV via PUT.
Path constructed from parent_ref + filename. SSRF guard applied.
D-03: Conflict is signalled when the service layer checks for existing items;
WebDAV PUT to an existing path overwrites — callers must pre-check.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'uploaded', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None, 'size': int}
"""
if parent_ref:
object_path = f"{parent_ref.rstrip('/')}/{filename}"
else:
object_path = filename
def _upload() -> dict:
try:
buf = io.BytesIO(content)
self._client.upload_to(buf, object_path)
return {
"kind": MUT_KIND_UPLOADED,
"reason": MUT_REASON_CREATED,
"provider_item_id": object_path,
"name": filename,
"parent_ref": parent_ref,
"size": len(content),
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_upload)
except Exception as exc:
return self._normalize_error(exc)