dify/api/services/knowledge_fs/object_storage.py

459 lines
18 KiB
Python

"""KnowledgeFS object operations backed by Dify's configured storage provider.
KnowledgeFS is a separate Node process, so it reaches this service through a
trusted inner API rather than importing Python storage implementations or
holding a second set of provider credentials. Logical KnowledgeFS keys are
confined below a dedicated physical namespace. Metadata is stored as a small
sidecar because Dify's common ``BaseStorage`` contract intentionally exposes
portable byte operations rather than provider-specific object metadata.
Object keys are immutable/versioned in KnowledgeFS product flows. A backend
must also support ``scan`` for bounded logical pagination and cleanup; health
fails closed when that capability is unavailable.
"""
from __future__ import annotations
import json
from base64 import b64decode, b64encode
from collections.abc import Generator, Mapping
from dataclasses import dataclass
from hashlib import sha256
from typing import NotRequired, Protocol, TypedDict
from extensions.ext_storage import storage
KNOWLEDGE_FS_OBJECT_MAX_BYTES = 64 * 1024 * 1024
_MAX_KEY_BYTES = 1024
_MAX_LIST_LIMIT = 100
_MAX_METADATA_ENTRIES = 64
_MAX_METADATA_KEY_BYTES = 64
_MAX_METADATA_VALUE_BYTES = 1024
_DATA_ROOT = "knowledge-fs/objects"
_METADATA_ROOT = "knowledge-fs/object-metadata"
_HEALTH_SCAN_ROOT = "knowledge-fs/health-capability"
class KnowledgeFSObjectStorageError(Exception):
"""Base error for the trusted KnowledgeFS storage gateway."""
class KnowledgeFSObjectStorageInvalidInputError(KnowledgeFSObjectStorageError):
"""The caller supplied an invalid key, cursor, metadata value, or limit."""
class KnowledgeFSObjectStorageTooLargeError(KnowledgeFSObjectStorageInvalidInputError):
"""The caller supplied an object body above the portable gateway limit."""
class KnowledgeFSObjectStorageChecksumError(KnowledgeFSObjectStorageError):
"""The supplied checksum does not match the uploaded object body."""
class KnowledgeFSObjectStorageCorruptError(KnowledgeFSObjectStorageError):
"""The physical object and its portable metadata sidecar disagree."""
class KnowledgeFSObjectStorageUnavailableError(KnowledgeFSObjectStorageError):
"""The selected Dify storage backend lacks a required portable capability."""
class _StorageBackend(Protocol):
def save(self, filename: str, data: bytes) -> None: ...
def load_once(self, filename: str) -> bytes: ...
def load_stream(self, filename: str) -> Generator[bytes, None, None]: ...
def exists(self, filename: str) -> bool: ...
def delete(self, filename: str) -> None: ...
def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]: ...
class _StoredMetadataPayload(TypedDict):
checksum_sha256_base64: str
content_type: str | None
key: str
metadata: dict[str, str]
size_bytes: int
version: int
source_path: NotRequired[str]
@dataclass(frozen=True, slots=True)
class _DecodedStoredMetadata:
metadata: KnowledgeFSObjectMetadata
source_path: str | None = None
@dataclass(frozen=True, slots=True)
class KnowledgeFSObjectMetadata:
"""Portable metadata returned to the KnowledgeFS adapter."""
checksum_sha256_base64: str
content_type: str | None
key: str
metadata: Mapping[str, str]
size_bytes: int
@dataclass(frozen=True, slots=True)
class KnowledgeFSObjectList:
"""One keyset-paginated page of logical KnowledgeFS objects."""
objects: tuple[KnowledgeFSObjectMetadata, ...]
next_cursor: str | None = None
class KnowledgeFSObjectStorageService:
"""Confine KnowledgeFS object I/O to Dify's unified storage namespace."""
_backend: _StorageBackend
def __init__(self, *, backend: _StorageBackend = storage) -> None:
self._backend = backend
def put_object(
self,
*,
key: str,
body: bytes,
metadata: Mapping[str, str],
checksum_sha256_base64: str | None = None,
content_type: str | None = None,
) -> KnowledgeFSObjectMetadata:
"""Validate and persist one immutable logical object and its metadata sidecar."""
normalized_key = _normalize_key(key)
normalized_metadata = _normalize_metadata(metadata)
normalized_content_type = _normalize_content_type(content_type)
if len(body) > KNOWLEDGE_FS_OBJECT_MAX_BYTES:
raise KnowledgeFSObjectStorageTooLargeError(f"object exceeds max bytes {KNOWLEDGE_FS_OBJECT_MAX_BYTES}")
actual_checksum = b64encode(sha256(body).digest()).decode()
if checksum_sha256_base64 is not None:
expected_checksum = _normalize_checksum(checksum_sha256_base64)
if expected_checksum != actual_checksum:
raise KnowledgeFSObjectStorageChecksumError("object checksum does not match body")
object_metadata = KnowledgeFSObjectMetadata(
checksum_sha256_base64=actual_checksum,
content_type=normalized_content_type,
key=normalized_key,
metadata=normalized_metadata,
size_bytes=len(body),
)
data_path = _data_path(normalized_key)
self._backend.save(data_path, body)
try:
self._backend.save(_metadata_path(normalized_key), _encode_metadata(object_metadata))
except Exception:
self._backend.delete(data_path)
raise
return object_metadata
def adopt_upload_file(
self,
*,
key: str,
source_path: str,
tenant_id: str,
size_bytes: int,
checksum_sha256_base64: str,
metadata: Mapping[str, str],
content_type: str | None = None,
) -> KnowledgeFSObjectMetadata:
"""Transfer one trusted Dify UploadFile into the KnowledgeFS logical namespace.
The document bytes stay at their existing unified-storage path. A small logical
marker and a v2 sidecar make the object visible to KnowledgeFS scans while reads
stream the original object. Deletion follows the reference and removes both the
logical marker and the transferred source object.
"""
normalized_key = _normalize_key(key)
normalized_source_path = _normalize_adopted_source_path(source_path, tenant_id=tenant_id)
normalized_metadata = _normalize_metadata(metadata)
normalized_content_type = _normalize_content_type(content_type)
normalized_checksum = _normalize_checksum(checksum_sha256_base64)
if not isinstance(size_bytes, int) or isinstance(size_bytes, bool) or size_bytes <= 0:
raise KnowledgeFSObjectStorageInvalidInputError("adopted object size is invalid")
if size_bytes > KNOWLEDGE_FS_OBJECT_MAX_BYTES:
raise KnowledgeFSObjectStorageTooLargeError(f"object exceeds max bytes {KNOWLEDGE_FS_OBJECT_MAX_BYTES}")
if not self._backend.exists(normalized_source_path):
raise KnowledgeFSObjectStorageInvalidInputError("adopted upload file is missing")
object_metadata = KnowledgeFSObjectMetadata(
checksum_sha256_base64=normalized_checksum,
content_type=normalized_content_type,
key=normalized_key,
metadata=normalized_metadata,
size_bytes=size_bytes,
)
existing = self._read_stored_metadata(normalized_key)
if existing is not None:
if existing != _DecodedStoredMetadata(metadata=object_metadata, source_path=normalized_source_path):
raise KnowledgeFSObjectStorageInvalidInputError("logical object already references different bytes")
return object_metadata
data_path = _data_path(normalized_key)
self._backend.save(data_path, b"")
try:
self._backend.save(
_metadata_path(normalized_key),
_encode_metadata(object_metadata, source_path=normalized_source_path),
)
except Exception:
self._backend.delete(data_path)
raise
return object_metadata
def head_object(self, *, key: str) -> KnowledgeFSObjectMetadata | None:
"""Return portable metadata without loading the object body."""
normalized_key = _normalize_key(key)
if not self._backend.exists(_data_path(normalized_key)):
return None
stored = self._read_stored_metadata(normalized_key)
if stored is None:
return None
if stored.source_path is not None and not self._backend.exists(stored.source_path):
raise KnowledgeFSObjectStorageCorruptError("adopted object source is missing")
return stored.metadata
def load_stream(self, *, key: str) -> Generator[bytes, None, None] | None:
"""Return the configured backend's stream for one logical object."""
normalized_key = _normalize_key(key)
stored = self._read_stored_metadata(normalized_key)
if stored is None:
return None
source_path = stored.source_path or _data_path(normalized_key)
if not self._backend.exists(source_path):
raise KnowledgeFSObjectStorageCorruptError("object body is missing")
return self._backend.load_stream(source_path)
def delete_object(self, *, key: str) -> None:
"""Idempotently delete an object and its portable metadata sidecar."""
normalized_key = _normalize_key(key)
stored = self._read_stored_metadata(normalized_key)
if stored is not None and stored.source_path is not None:
self._backend.delete(stored.source_path)
self._backend.delete(_data_path(normalized_key))
self._backend.delete(_metadata_path(normalized_key))
def _read_stored_metadata(self, normalized_key: str) -> _DecodedStoredMetadata | None:
if not self._backend.exists(_data_path(normalized_key)):
return None
try:
encoded = self._backend.load_once(_metadata_path(normalized_key))
except FileNotFoundError as exc:
raise KnowledgeFSObjectStorageCorruptError("object metadata sidecar is missing") from exc
stored = _decode_metadata(encoded)
if stored.metadata.key != normalized_key:
raise KnowledgeFSObjectStorageCorruptError("object metadata key does not match")
return stored
def list_objects(
self,
*,
prefix: str,
limit: int,
cursor: str | None = None,
) -> KnowledgeFSObjectList:
"""List logical keys using a stable lexical cursor and portable metadata."""
normalized_prefix = _normalize_prefix(prefix)
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= _MAX_LIST_LIMIT:
raise KnowledgeFSObjectStorageInvalidInputError("list limit must be between 1 and 100")
normalized_cursor = _normalize_key(cursor) if cursor is not None else None
if normalized_cursor is not None and not normalized_cursor.startswith(normalized_prefix):
raise KnowledgeFSObjectStorageInvalidInputError("list cursor must be within prefix")
try:
physical_paths = self._backend.scan(
_physical_scan_prefix(normalized_prefix),
files=True,
directories=False,
)
except FileNotFoundError:
physical_paths = []
except NotImplementedError as exc:
raise KnowledgeFSObjectStorageUnavailableError(
"configured Dify storage backend does not support object listing"
) from exc
except Exception as exc:
raise KnowledgeFSObjectStorageUnavailableError("Dify storage listing failed") from exc
data_prefix = f"{_DATA_ROOT}/"
logical_keys = sorted(
{
physical_path[len(data_prefix) :]
for physical_path in physical_paths
if physical_path.startswith(data_prefix)
and physical_path[len(data_prefix) :].startswith(normalized_prefix)
and (normalized_cursor is None or physical_path[len(data_prefix) :] > normalized_cursor)
}
)
page_keys = logical_keys[: limit + 1]
has_more = len(page_keys) > limit
objects: list[KnowledgeFSObjectMetadata] = []
for logical_key in page_keys[:limit]:
metadata = self.head_object(key=logical_key)
if metadata is not None:
objects.append(metadata)
next_cursor = objects[-1].key if has_more and objects else None
return KnowledgeFSObjectList(objects=tuple(objects), next_cursor=next_cursor)
def health(self) -> bool:
"""Check the portable list capability without scanning the object namespace."""
try:
self._backend.scan(_HEALTH_SCAN_ROOT, files=True, directories=False)
except FileNotFoundError:
return True
except Exception:
return False
return True
def _normalize_key(value: str) -> str:
if not isinstance(value, str):
raise KnowledgeFSObjectStorageInvalidInputError("object key must be a string")
normalized = value.strip()
segments = normalized.split("/")
if (
not normalized
or normalized.startswith("/")
or "\\" in normalized
or "\x00" in normalized
or any(segment in {"", ".", ".."} for segment in segments)
or len(normalized.encode()) > _MAX_KEY_BYTES
):
raise KnowledgeFSObjectStorageInvalidInputError("object key is invalid")
return normalized
def _normalize_prefix(value: str) -> str:
if value == "":
return value
normalized = value.strip()
candidate = normalized.removesuffix("/")
_normalize_key(candidate)
return normalized
def _normalize_metadata(metadata: Mapping[str, str]) -> dict[str, str]:
if len(metadata) > _MAX_METADATA_ENTRIES:
raise KnowledgeFSObjectStorageInvalidInputError("object metadata has too many entries")
normalized: dict[str, str] = {}
for key, value in metadata.items():
if (
not isinstance(key, str)
or not isinstance(value, str)
or not key
or len(key.encode()) > _MAX_METADATA_KEY_BYTES
or len(value.encode()) > _MAX_METADATA_VALUE_BYTES
):
raise KnowledgeFSObjectStorageInvalidInputError("object metadata is invalid")
normalized[key] = value
return normalized
def _normalize_content_type(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized or len(normalized) > 255 or "\r" in normalized or "\n" in normalized:
raise KnowledgeFSObjectStorageInvalidInputError("object content type is invalid")
return normalized
def _normalize_checksum(value: str) -> str:
try:
decoded = b64decode(value, validate=True)
except ValueError as exc:
raise KnowledgeFSObjectStorageInvalidInputError("object checksum is invalid") from exc
if len(decoded) != 32 or b64encode(decoded).decode() != value:
raise KnowledgeFSObjectStorageInvalidInputError("object checksum is invalid")
return value
def _normalize_adopted_source_path(value: str, *, tenant_id: str) -> str:
if not isinstance(value, str) or not isinstance(tenant_id, str):
raise KnowledgeFSObjectStorageInvalidInputError("adopted upload path is invalid")
normalized_tenant_id = tenant_id.strip()
normalized = value.strip()
expected_prefix = f"upload_files/{normalized_tenant_id}/"
relative = normalized.removeprefix(expected_prefix)
if (
not normalized_tenant_id
or not normalized.startswith(expected_prefix)
or not relative
or "/" in relative
or "\\" in normalized
or "\x00" in normalized
or relative in {".", ".."}
):
raise KnowledgeFSObjectStorageInvalidInputError("adopted upload path is invalid")
return normalized
def _data_path(key: str) -> str:
return f"{_DATA_ROOT}/{key}"
def _metadata_path(key: str) -> str:
digest = sha256(key.encode()).hexdigest()
return f"{_METADATA_ROOT}/{digest[:2]}/{digest[2:4]}/{digest}.json"
def _physical_scan_prefix(prefix: str) -> str:
parent = prefix.rstrip("/").rpartition("/")[0]
return f"{_DATA_ROOT}/{parent}" if parent else _DATA_ROOT
def _encode_metadata(metadata: KnowledgeFSObjectMetadata, *, source_path: str | None = None) -> bytes:
payload: _StoredMetadataPayload = {
"checksum_sha256_base64": metadata.checksum_sha256_base64,
"content_type": metadata.content_type,
"key": metadata.key,
"metadata": dict(metadata.metadata),
"size_bytes": metadata.size_bytes,
"version": 2 if source_path is not None else 1,
}
if source_path is not None:
payload["source_path"] = source_path
return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
def _decode_metadata(encoded: bytes) -> _DecodedStoredMetadata:
try:
payload = json.loads(encoded)
if not isinstance(payload, dict) or payload.get("version") not in {1, 2}:
raise ValueError
key = _normalize_key(payload["key"])
checksum = _normalize_checksum(payload["checksum_sha256_base64"])
content_type = _normalize_content_type(payload["content_type"])
metadata = _normalize_metadata(payload["metadata"])
size_bytes = payload["size_bytes"]
if not isinstance(size_bytes, int) or isinstance(size_bytes, bool) or size_bytes < 0:
raise ValueError
source_path = payload.get("source_path")
if payload["version"] == 1:
if source_path is not None:
raise ValueError
else:
tenant_id = metadata.get("tenant_id")
if not isinstance(source_path, str) or not isinstance(tenant_id, str):
raise ValueError
source_path = _normalize_adopted_source_path(source_path, tenant_id=tenant_id)
except (KeyError, TypeError, ValueError, json.JSONDecodeError, KnowledgeFSObjectStorageInvalidInputError) as exc:
raise KnowledgeFSObjectStorageCorruptError("object metadata sidecar is invalid") from exc
return _DecodedStoredMetadata(
metadata=KnowledgeFSObjectMetadata(
checksum_sha256_base64=checksum,
content_type=content_type,
key=key,
metadata=metadata,
size_bytes=size_bytes,
),
source_path=source_path,
)