from __future__ import annotations import json from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timezone from email.utils import parsedate_to_datetime from importlib import import_module import mimetypes from typing import Any from urllib.parse import quote, unquote, urljoin, urlsplit import xml.etree.ElementTree as ET # nosec B405 - typing/element creation only; parsing uses defusedxml below. from defusedxml import ElementTree as SafeElementTree from govoplan_core.security.outbound_http import ( OutboundHttpError, validate_unpinned_sdk_host, validate_unpinned_sdk_http_url, ) from govoplan_files.backend.storage.http_client import ConnectorHttpError, request_connector_bytes from govoplan_files.backend.storage.connector_deployment import ( ConnectorDeploymentConfigurationError, connector_ca_bundle_path, connector_secret_env_value, validate_connector_tls_metadata, ) from govoplan_files.backend.storage.connector_profiles import ConnectorProfile class ConnectorBrowseError(RuntimeError): pass class ConnectorBrowseUnsupported(ConnectorBrowseError): pass @dataclass(frozen=True, slots=True) class ConnectorBrowseItem: kind: str name: str path: str external_id: str | None = None external_url: str | None = None size_bytes: int | None = None content_type: str | None = None modified_at: str | None = None etag: str | None = None metadata: Mapping[str, Any] = field(default_factory=dict) def to_response(self) -> dict[str, Any]: return { "kind": self.kind, "name": self.name, "path": self.path, "external_id": self.external_id, "external_url": self.external_url, "size_bytes": self.size_bytes, "content_type": self.content_type, "modified_at": self.modified_at, "etag": self.etag, "metadata": dict(self.metadata), } def browse_connector_profile(profile: ConnectorProfile, *, path: str | None = None, library_id: str | None = None, continuation_token: str | None = None) -> list[ConnectorBrowseItem]: browse_path = normalize_connector_browse_path(path) static_items = _static_listing(profile, path=browse_path, library_id=library_id) if static_items is not None: return static_items if profile.provider == "seafile": if _metadata_string(profile, "browse_protocol") == "webdav" or _metadata_string(profile, "webdav_endpoint_url"): return _browse_webdav(profile, path=browse_path) return _browse_seafile(profile, path=browse_path, library_id=library_id) if profile.provider in {"webdav", "nextcloud"} or _metadata_string(profile, "browse_protocol") == "webdav": return _browse_webdav(profile, path=browse_path) if profile.provider == "smb": return _browse_smb(profile, path=browse_path) if profile.provider == "s3": return _browse_s3(profile, path=browse_path, library_id=library_id, continuation_token=continuation_token) raise ConnectorBrowseUnsupported(f"Read-only browsing is not implemented for {profile.provider} connector profiles yet") def parse_webdav_multistatus(*, root_url: str, current_path: str, payload: str | bytes) -> list[ConnectorBrowseItem]: try: root = SafeElementTree.fromstring(payload) except SafeElementTree.ParseError as exc: raise ConnectorBrowseError("Connector returned invalid WebDAV XML") from exc root_path = _url_path_root(root_url) current = normalize_connector_browse_path(current_path) items: list[ConnectorBrowseItem] = [] for response in root.findall("{DAV:}response"): href = response.findtext("{DAV:}href") if not href: continue relative_path = _relative_href_path(root_path, href) if relative_path == current: continue if current and not relative_path.startswith(current.rstrip("/") + "/"): continue parent = relative_path.rsplit("/", 1)[0] if "/" in relative_path else "" if parent != current: continue prop = _webdav_prop(response) is_collection = prop.find("{DAV:}resourcetype/{DAV:}collection") is not None if prop is not None else False name = _webdav_display_name(prop) or _path_name(relative_path) if not name: continue items.append( ConnectorBrowseItem( kind="folder" if is_collection else "file", name=name, path=relative_path, external_id=_text(prop, "{DAV:}getetag") or relative_path, size_bytes=None if is_collection else _int(_text(prop, "{DAV:}getcontentlength")), content_type=None if is_collection else _text(prop, "{DAV:}getcontenttype"), modified_at=_http_date(_text(prop, "{DAV:}getlastmodified")), etag=_text(prop, "{DAV:}getetag"), ) ) return sorted(items, key=lambda item: (item.kind != "library", item.kind != "folder", item.name.casefold(), item.path.casefold())) def seafile_libraries_from_payload(payload: object) -> list[ConnectorBrowseItem]: if not isinstance(payload, list): raise ConnectorBrowseError("Seafile library response must be a list") libraries = [_seafile_library_item(item) for item in payload if isinstance(item, Mapping)] return sorted(libraries, key=lambda item: item.name.casefold()) def seafile_directory_items_from_payload(payload: object, *, path: str | None = None) -> list[ConnectorBrowseItem]: if payload == "uptodate": return [] if not isinstance(payload, list): raise ConnectorBrowseError("Seafile directory response must be a list") browse_path = normalize_connector_browse_path(path) items = [_seafile_directory_item(item, parent_path=browse_path) for item in payload if isinstance(item, Mapping)] return sorted(items, key=lambda item: (item.kind != "folder", item.name.casefold(), item.path.casefold())) def _browse_seafile(profile: ConnectorProfile, *, path: str, library_id: str | None) -> list[ConnectorBrowseItem]: repo_id, dir_path = _seafile_repo_and_path(path=path, library_id=library_id) headers = _seafile_headers(profile) if not repo_id: payload = _request_json("GET", _seafile_url(profile, "api2/repos/"), headers=headers) return seafile_libraries_from_payload(payload) payload = _request_json( "GET", _seafile_url(profile, f"api2/repos/{quote(repo_id, safe='')}/dir/"), headers=headers, params={"p": "/" + dir_path if dir_path else "/"}, ) return seafile_directory_items_from_payload(payload, path=dir_path) def _browse_webdav(profile: ConnectorProfile, *, path: str) -> list[ConnectorBrowseItem]: root_url = _metadata_string(profile, "webdav_endpoint_url") or profile.endpoint_url if not root_url: raise ConnectorBrowseError("Connector profile does not define an endpoint URL") url = _webdav_url(root_url, path) headers = {"Depth": "1", "Content-Type": "application/xml; charset=utf-8"} auth: tuple[str, str] | None = None password = _profile_password(profile) token = _profile_token(profile) if profile.username and password: auth = (profile.username, password) elif token: headers["Authorization"] = f"Bearer {token}" elif profile.credential_mode.casefold() not in {"", "none", "anonymous"} and profile.secret_ref: raise ConnectorBrowseError("Secret-ref connector credentials need a runtime secret resolver before live browsing") body = """ """ try: response = request_connector_bytes( "PROPFIND", url, headers=headers, content=body, auth=auth, timeout=15.0, label="WebDAV browse", ) except ConnectorHttpError as exc: raise ConnectorBrowseError(f"Connector browse failed: {exc}") from exc if response.status_code in {401, 403}: raise ConnectorBrowseError("Connector credentials were rejected") if response.status_code not in {200, 207}: raise ConnectorBrowseError(f"Connector browse failed with HTTP {response.status_code}") return parse_webdav_multistatus(root_url=root_url, current_path=path, payload=response.content) def _browse_smb(profile: ConnectorProfile, *, path: str) -> list[ConnectorBrowseItem]: location = _smb_location(profile) unc_path = _smb_unc_path(location, path) smbclient = _smbclient_module() try: entries = smbclient.scandir(unc_path, **_smb_client_kwargs(profile, location)) except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific raise ConnectorBrowseError(f"SMB connector browse failed: {exc}") from exc items: list[ConnectorBrowseItem] = [] try: with entries as iterator: for entry in iterator: name = _clean(getattr(entry, "name", None)) if not name or name in {".", ".."}: continue try: is_dir = bool(entry.is_dir()) except Exception: is_dir = False stat_result = _smb_entry_stat(entry) item_path = _join_browse_path(path, name) items.append( ConnectorBrowseItem( kind="folder" if is_dir else "file", name=name, path=item_path, external_id=f"{location.share}:{item_path}", size_bytes=None if is_dir else _smb_stat_size(stat_result), content_type=None if is_dir else mimetypes.guess_type(name)[0], modified_at=_smb_stat_modified_at(stat_result), etag=_smb_stat_revision(stat_result), metadata={ "share": location.share, **({"server": location.server} if _metadata_bool(profile, "expose_server_metadata", default=False) else {}), }, ) ) except ConnectorBrowseError: raise except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific raise ConnectorBrowseError(f"SMB connector browse failed: {exc}") from exc return sorted(items, key=lambda item: (item.kind != "folder", item.name.casefold(), item.path.casefold())) def _browse_s3(profile: ConnectorProfile, *, path: str, library_id: str | None, continuation_token: str | None) -> list[ConnectorBrowseItem]: client = _s3_client(profile) bucket = _s3_bucket(profile, library_id) if not bucket: try: payload = client.list_buckets() except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific raise ConnectorBrowseError(f"S3 connector browse failed: {exc}") from exc buckets = payload.get("Buckets") if isinstance(payload, Mapping) else None if not isinstance(buckets, list): raise ConnectorBrowseError("S3 connector returned invalid bucket list") return sorted( ( ConnectorBrowseItem( kind="library", name=_clean(item.get("Name")) or "", path=_clean(item.get("Name")) or "", external_id=_clean(item.get("Name")), modified_at=_timestamp(item.get("CreationDate")), metadata={"bucket": _clean(item.get("Name"))}, ) for item in buckets if isinstance(item, Mapping) and _clean(item.get("Name")) ), key=lambda item: item.name.casefold(), ) prefix = _s3_object_key(profile, path, directory=True) params: dict[str, object] = { "Bucket": bucket, "Prefix": prefix, "Delimiter": "/", "MaxKeys": _s3_max_keys(profile), } next_page_request = _clean(continuation_token) or _metadata_string(profile, "continuation_token") if next_page_request: params["ContinuationToken"] = next_page_request try: payload = client.list_objects_v2(**params) except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific raise ConnectorBrowseError(f"S3 connector browse failed: {exc}") from exc if not isinstance(payload, Mapping): raise ConnectorBrowseError("S3 connector returned invalid object listing") items = [ *_s3_prefix_items(bucket=bucket, browse_path=path, base_prefix=prefix, prefixes=payload.get("CommonPrefixes")), *_s3_object_items(bucket=bucket, browse_path=path, base_prefix=prefix, objects=payload.get("Contents")), ] next_token = _clean(payload.get("NextContinuationToken")) if next_token and items: last = items[-1] items[-1] = ConnectorBrowseItem( kind=last.kind, name=last.name, path=last.path, external_id=last.external_id, external_url=last.external_url, size_bytes=last.size_bytes, content_type=last.content_type, modified_at=last.modified_at, etag=last.etag, metadata={**dict(last.metadata), "next_continuation_token": next_token, "listing_truncated": True}, ) return sorted(items, key=lambda item: (item.kind != "folder", item.name.casefold(), item.path.casefold())) def _s3_client(profile: ConnectorProfile) -> Any: if profile.secret_ref: raise ConnectorBrowseError("Secret-ref S3 credentials need a runtime secret resolver before live browsing") if profile.endpoint_url: try: endpoint_url = validate_unpinned_sdk_http_url( profile.endpoint_url, label="S3 connector endpoint", ) except OutboundHttpError as exc: raise ConnectorBrowseError(str(exc)) from exc else: raise ConnectorBrowseError( "S3 connector endpoint discovery uses an SDK transport that cannot guarantee connection-time DNS/IP " "pinning; live S3 access is disabled until that transport supports pinning" ) try: boto3 = import_module("boto3") config_module = import_module("botocore.config") except ImportError as exc: raise ConnectorBrowseUnsupported("S3 connector browsing requires the optional boto3 dependency") from exc kwargs: dict[str, object] = {"endpoint_url": endpoint_url} region = _metadata_string(profile, "region") or _metadata_string(profile, "aws_region") if region: kwargs["region_name"] = region access_key = profile.username or _metadata_env(profile, "access_key_id_env") or _metadata_string(profile, "access_key_id") secret_key = _profile_password(profile) or _metadata_env(profile, "secret_access_key_env") session_token = _profile_token(profile) or _metadata_env(profile, "session_token_env") if access_key: kwargs["aws_access_key_id"] = access_key if secret_key: kwargs["aws_secret_access_key"] = secret_key if session_token: kwargs["aws_session_token"] = session_token verify = _s3_verify(profile) if verify is not None: kwargs["verify"] = verify addressing_style = _s3_addressing_style(profile) if addressing_style: kwargs["config"] = config_module.Config(s3={"addressing_style": addressing_style}) try: return boto3.client("s3", **kwargs) except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific raise ConnectorBrowseError(f"S3 connector could not be initialized: {exc}") from exc def _s3_bucket(profile: ConnectorProfile, library_id: str | None = None) -> str | None: return _metadata_string(profile, "bucket") or _metadata_string(profile, "bucket_name") or _clean(library_id) def _s3_object_key(profile: ConnectorProfile, path: str, *, directory: bool = False) -> str: base_prefix = normalize_connector_browse_path(profile.base_path or _metadata_string(profile, "base_prefix")) browse_path = normalize_connector_browse_path(path) key = "/".join(part for part in (base_prefix, browse_path) if part) if directory and key: return key.rstrip("/") + "/" return key def _s3_prefix_items( *, bucket: str, browse_path: str, base_prefix: str, prefixes: object, ) -> list[ConnectorBrowseItem]: if not isinstance(prefixes, list): return [] items: list[ConnectorBrowseItem] = [] for item in prefixes: if not isinstance(item, Mapping): continue key = _clean(item.get("Prefix")) if not key: continue relative = _s3_relative_key(key, base_prefix=base_prefix) name = _path_name(relative) if not name: continue path = _join_browse_path(browse_path, name) items.append( ConnectorBrowseItem( kind="folder", name=name, path=path, external_id=f"{bucket}:{key}", metadata={"bucket": bucket, "key": key}, ) ) return items def _s3_object_items( *, bucket: str, browse_path: str, base_prefix: str, objects: object, ) -> list[ConnectorBrowseItem]: if not isinstance(objects, list): return [] items: list[ConnectorBrowseItem] = [] for item in objects: if not isinstance(item, Mapping): continue key = _clean(item.get("Key")) if not key or key == base_prefix: continue relative = _s3_relative_key(key, base_prefix=base_prefix) name = _path_name(relative) if not name: continue path = _join_browse_path(browse_path, name) items.append( ConnectorBrowseItem( kind="file", name=name, path=path, external_id=f"{bucket}:{key}", size_bytes=_int(item.get("Size")), content_type=mimetypes.guess_type(name)[0], modified_at=_timestamp(item.get("LastModified")), etag=_clean(item.get("ETag")), metadata={ "bucket": bucket, "key": key, **({"storage_class": item["StorageClass"]} if "StorageClass" in item else {}), }, ) ) return items def _s3_relative_key(key: str, *, base_prefix: str) -> str: clean_key = key.rstrip("/") clean_base = base_prefix.rstrip("/") if clean_base and clean_key.startswith(f"{clean_base}/"): return clean_key[len(clean_base) + 1 :] return clean_key def _s3_max_keys(profile: ConnectorProfile) -> int: configured = _int(profile.metadata.get("max_keys")) if configured is None: return 1000 return max(1, min(configured, 1000)) def _s3_verify(profile: ConnectorProfile) -> bool | str | None: try: validate_connector_tls_metadata(profile.metadata) except ConnectorDeploymentConfigurationError as exc: raise ConnectorBrowseError(str(exc)) from exc ca_bundle = _metadata_string(profile, "ca_bundle") if ca_bundle: try: return connector_ca_bundle_path(ca_bundle) except ConnectorDeploymentConfigurationError as exc: raise ConnectorBrowseError(str(exc)) from exc if "verify_tls" in profile.metadata: return _metadata_bool(profile, "verify_tls", default=True) if "tls_verify" in profile.metadata: return _metadata_bool(profile, "tls_verify", default=True) return None def _s3_addressing_style(profile: ConnectorProfile) -> str | None: style = _metadata_string(profile, "addressing_style") if style in {"path", "virtual", "auto"}: return style if _metadata_bool(profile, "path_style", default=False): return "path" return None def _seafile_headers(profile: ConnectorProfile) -> dict[str, str]: token = _seafile_token(profile) return {"Authorization": f"Token {token}", "Accept": "application/json"} def _seafile_token(profile: ConnectorProfile) -> str: token = _profile_token(profile) if token: return token password = _profile_password(profile) if profile.username and password: payload = _request_json( "POST", _seafile_url(profile, "api2/auth-token/"), data={"username": profile.username, "password": password}, ) if not isinstance(payload, Mapping) or not _clean(payload.get("token")): raise ConnectorBrowseError("Seafile did not return an account token") return _clean(payload.get("token")) or "" if profile.secret_ref: raise ConnectorBrowseError("Secret-ref Seafile credentials need a runtime secret resolver before live browsing") raise ConnectorBrowseError("Seafile connector profiles require token credentials or username plus password credentials") def _seafile_url(profile: ConnectorProfile, suffix: str) -> str: if not profile.endpoint_url: raise ConnectorBrowseError("Seafile connector profile does not define endpoint_url") return urljoin(profile.endpoint_url.rstrip("/") + "/", suffix.lstrip("/")) def _request_json( method: str, url: str, *, headers: Mapping[str, str] | None = None, params: Mapping[str, str] | None = None, data: Mapping[str, str] | None = None, ) -> object: try: response = request_connector_bytes( method, url, headers=dict(headers or {}), params=params, data=data, timeout=15.0, label="Seafile API", ) except ConnectorHttpError as exc: raise ConnectorBrowseError(f"Connector browse failed: {exc}") from exc if response.status_code in {401, 403}: raise ConnectorBrowseError("Connector credentials were rejected") if response.status_code not in {200, 201}: raise ConnectorBrowseError(f"Connector browse failed with HTTP {response.status_code}") try: return json.loads(response.content) except (UnicodeDecodeError, ValueError) as exc: raise ConnectorBrowseError("Connector returned invalid JSON") from exc def _seafile_repo_and_path(*, path: str, library_id: str | None) -> tuple[str | None, str]: repo_id = _clean(library_id) if repo_id: return repo_id, path if not path: return None, "" first, _, rest = path.partition("/") return first, rest def _seafile_library_item(value: Mapping[str, Any]) -> ConnectorBrowseItem: repo_id = _clean(value.get("id") or value.get("repo_id")) name = _clean(value.get("name") or value.get("repo_name") or repo_id) if not repo_id or not name: raise ConnectorBrowseError("Seafile library entries require id and name") return ConnectorBrowseItem( kind="library", name=name, path=repo_id, external_id=repo_id, size_bytes=_int(value.get("size") or value.get("repo_size")), modified_at=_timestamp(value.get("mtime")), metadata={ key: value[key] for key in ("type", "permission", "encrypted", "owner", "file_count") if key in value }, ) def _seafile_directory_item(value: Mapping[str, Any], *, parent_path: str) -> ConnectorBrowseItem: name = _clean(value.get("name") or value.get("obj_name")) if not name: raise ConnectorBrowseError("Seafile directory entries require name") item_type = str(value.get("type") or ("dir" if value.get("is_dir") else "file")).casefold() kind = "folder" if item_type in {"dir", "folder"} else "file" path = _join_browse_path(parent_path, name) content_type = None if kind == "folder" else mimetypes.guess_type(name)[0] return ConnectorBrowseItem( kind=kind, name=name, path=path, external_id=_clean(value.get("id") or value.get("obj_id")), size_bytes=None if kind == "folder" else _int(value.get("size")), content_type=content_type, modified_at=_timestamp(value.get("mtime") or value.get("modified")), etag=_clean(value.get("id") or value.get("obj_id")), metadata={ key: value[key] for key in ("permission", "modifier_email", "modifier_name") if key in value }, ) def _static_listing(profile: ConnectorProfile, *, path: str, library_id: str | None) -> list[ConnectorBrowseItem] | None: listing = profile.metadata.get("static_listing") if listing is None: return None if isinstance(listing, list): raw_items = listing if path == "" else [] elif isinstance(listing, Mapping): key = library_id or path raw_items = listing.get(key) if raw_items is None and key == "": raw_items = listing.get("/") else: raise ConnectorBrowseError("Connector static_listing metadata must be a list or object") if raw_items is None: return [] if not isinstance(raw_items, list): raise ConnectorBrowseError("Connector static_listing entries must be lists") return sorted( [_static_item(item, parent_path=path) for item in raw_items if isinstance(item, Mapping)], key=lambda item: (item.kind != "library", item.kind != "folder", item.name.casefold(), item.path.casefold()), ) def _static_item(value: Mapping[str, Any], *, parent_path: str) -> ConnectorBrowseItem: kind = str(value.get("kind") or "file").strip().casefold() if kind not in {"library", "folder", "file"}: raise ConnectorBrowseError("Connector browse items must be library, folder or file") name = str(value.get("name") or value.get("path") or "").strip() if not name: raise ConnectorBrowseError("Connector browse items require name") path = normalize_connector_browse_path(value.get("path")) if not path: path = _join_browse_path(parent_path, name) return ConnectorBrowseItem( kind=kind, name=name, path=path, external_id=_clean(value.get("external_id")), external_url=_clean(value.get("external_url")), size_bytes=_int(value.get("size_bytes")), content_type=_clean(value.get("content_type")), modified_at=_clean(value.get("modified_at")), etag=_clean(value.get("etag")), metadata=value.get("metadata") if isinstance(value.get("metadata"), Mapping) else {}, ) def _webdav_url(root_url: str, path: str) -> str: base = root_url if root_url.endswith("/") else root_url + "/" if not path: return base quoted = "/".join(quote(part, safe="") for part in path.split("/") if part) return urljoin(base, quoted + "/") def _url_path_root(root_url: str) -> str: path = unquote(urlsplit(root_url).path or "/") return path if path.endswith("/") else path + "/" def _relative_href_path(root_path: str, href: str) -> str: href_path = unquote(urlsplit(href).path or href).strip() if href_path.startswith(root_path): return normalize_connector_browse_path(href_path[len(root_path):]) return normalize_connector_browse_path(href_path.rsplit("/", 1)[-1]) def _webdav_prop(response: ET.Element) -> ET.Element: prop = response.find("{DAV:}propstat/{DAV:}prop") if prop is not None: return prop prop = response.find(".//{DAV:}prop") return prop if prop is not None else ET.Element("prop") def _webdav_display_name(prop: ET.Element | None) -> str | None: return _clean(_text(prop, "{DAV:}displayname")) def _text(prop: ET.Element | None, tag: str) -> str | None: if prop is None: return None child = prop.find(tag) return child.text.strip() if child is not None and child.text else None def _http_date(value: str | None) -> str | None: if not value: return None try: return parsedate_to_datetime(value).isoformat() except (TypeError, ValueError): return value def _timestamp(value: object) -> str | None: number = _int(value) if number is None: return _clean(value) return datetime.fromtimestamp(number, tz=timezone.utc).isoformat() def _metadata_string(profile: ConnectorProfile, key: str) -> str | None: return _clean(profile.metadata.get(key)) def _metadata_bool(profile: ConnectorProfile, key: str, *, default: bool = False) -> bool: value = profile.metadata.get(key) if value is None: return default if isinstance(value, bool): return value return str(value).strip().casefold() in {"1", "true", "yes", "on"} def _metadata_env(profile: ConnectorProfile, key: str) -> str | None: env_name = _metadata_string(profile, key) if not env_name: return None return _env_required(env_name, profile) def _env_required(name: str, profile: ConnectorProfile) -> str: try: return connector_secret_env_value(name, source_kind=profile.source_kind) except ConnectorDeploymentConfigurationError as exc: raise ConnectorBrowseError(f"Connector profile {profile.id}: {exc}") from exc def normalize_connector_browse_path(value: object) -> str: if value is None: return "" path = str(value).replace("\\", "/").strip().strip("/") parts = [part for part in path.split("/") if part and part not in {"."}] if any(part == ".." for part in parts): raise ConnectorBrowseError("Connector browse paths cannot contain parent directory segments") return "/".join(parts) def _join_browse_path(parent: str, name: str) -> str: child = normalize_connector_browse_path(name) if not parent: return child return f"{parent.rstrip('/')}/{child}" def _path_name(path: str) -> str: return path.rstrip("/").rsplit("/", 1)[-1] def _int(value: object) -> int | None: if value is None or value == "": return None try: return int(value) except (TypeError, ValueError): return None def _clean(value: object) -> str | None: if value is None: return None text = str(value).strip() return text or None @dataclass(frozen=True, slots=True) class _SmbLocation: server: str share: str port: int root_path: str = "" def _smb_location(profile: ConnectorProfile) -> _SmbLocation: if not profile.endpoint_url: raise ConnectorBrowseError("SMB connector profile does not define endpoint_url") parsed = urlsplit(profile.endpoint_url) if parsed.scheme.casefold() != "smb": raise ConnectorBrowseError("SMB connector endpoint_url must use smb://") server = _clean(parsed.hostname) if not server: raise ConnectorBrowseError("SMB connector endpoint_url must include a server") port = parsed.port or _int(profile.metadata.get("port")) or 445 try: validate_unpinned_sdk_host(server, port=port, label="SMB connector endpoint") except OutboundHttpError as exc: raise ConnectorBrowseError(str(exc)) from exc path_parts = [part for part in unquote(parsed.path or "").strip("/").split("/") if part] if not path_parts: raise ConnectorBrowseError("SMB connector endpoint_url must include a share name") root_parts = path_parts[1:] if profile.base_path: root_parts.extend(normalize_connector_browse_path(profile.base_path).split("/")) return _SmbLocation( server=server, share=path_parts[0], port=port, root_path=normalize_connector_browse_path("/".join(root_parts)), ) def _smb_unc_path(location: _SmbLocation, path: str) -> str: parts = [part for part in (location.root_path, normalize_connector_browse_path(path)) if part] suffix = "\\".join(part.replace("/", "\\") for part in parts) base = f"\\\\{location.server}\\{location.share}" return f"{base}\\{suffix}" if suffix else base def _smb_client_kwargs(profile: ConnectorProfile, location: _SmbLocation) -> dict[str, object]: kwargs: dict[str, object] = { "port": location.port, "require_signing": _metadata_bool(profile, "require_signing", default=True), "auth_protocol": _metadata_string(profile, "auth_protocol") or "ntlm", } if "encrypt" in profile.metadata: kwargs["encrypt"] = _metadata_bool(profile, "encrypt", default=False) password = _profile_password(profile) token = _profile_token(profile) if profile.username and password: kwargs["username"] = profile.username kwargs["password"] = password elif token: raise ConnectorBrowseError("SMB connector profiles do not support bearer-token credentials") elif profile.credential_mode.casefold() not in {"", "none", "anonymous"} and profile.secret_ref: raise ConnectorBrowseError("Secret-ref SMB credentials need a runtime secret resolver before live browsing") return kwargs def _profile_password(profile: ConnectorProfile) -> str | None: if profile.password_value: return profile.password_value if profile.password_env: return _env_required(profile.password_env, profile) return None def _profile_token(profile: ConnectorProfile) -> str | None: if profile.token_value: return profile.token_value if profile.token_env: return _env_required(profile.token_env, profile) return None def _smbclient_module() -> Any: try: return import_module("smbclient") except ImportError as exc: raise ConnectorBrowseUnsupported("SMB connector browsing requires the optional smbprotocol dependency") from exc def _smb_entry_stat(entry: object) -> object | None: try: return entry.stat() # type: ignore[attr-defined] except Exception: return None def _smb_stat_size(stat_result: object | None) -> int | None: return _int(getattr(stat_result, "st_size", None)) def _smb_stat_modified_at(stat_result: object | None) -> str | None: value = getattr(stat_result, "st_mtime", None) if value is None: return None try: return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat() except (TypeError, ValueError, OSError, OverflowError): return None def _smb_stat_revision(stat_result: object | None) -> str | None: mtime_ns = getattr(stat_result, "st_mtime_ns", None) size = getattr(stat_result, "st_size", None) if mtime_ns is not None: return f"{mtime_ns}:{size or 0}" modified = getattr(stat_result, "st_mtime", None) if modified is not None: return f"{modified}:{size or 0}" return None