← Files VeraARCHIVED FILE

modules/registro-imprese-sari/scripts/sari_connector.py

26.6 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""Bounded read-only connector for a user-selected public SARI lookup.

SARI's JSON routes are public frontend implementation details, not a documented
API. This connector therefore fails closed unless the caller records a separate
written-use authorization. Invoking the connector is the network-route choice;
it does not manufacture a second approval reference. The ordinary workflow uses
the public browser and registers the user-selected official result with
``register_official_source.py`` instead.
"""

from __future__ import annotations

import argparse
import http.cookiejar
import json
import logging
import re
import socket
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from html.parser import HTMLParser
from pathlib import Path
from typing import Any

from case_core import (
    PLUGIN_NAME,
    AssuranceContractError,
    assert_generic_public_query,
    ensure_safe_output_dir,
    iso_now,
    load_json_object,
    load_running_case_context,
    normalize_html_text,
    require_case_artifact_run,
    safe_identifier,
    sha256_bytes,
    write_private_json,
)

__all__ = [
    "SariClient",
    "SariConnectorError",
    "normalize_card",
    "normalize_search_result",
    "run_detail",
    "run_search",
    "main",
]

LOGGER = logging.getLogger(__name__)
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
SARI_ORIGIN = "https://supportospecialisticori.infocamere.it"
SARI_PREFIX = "/sariWeb/"
SARI_HOST = "supportospecialisticori.infocamere.it"
MAX_RESPONSE_BYTES = 2_000_000
MAX_RESULTS = 10
MAX_REQUESTS_PER_OPERATION = 2
TENANT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,23}$")
CARD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$")
AUTHORIZATION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{2,119}$")


class SariConnectorError(RuntimeError):
    """Raised when a SARI request violates the bounded connector contract."""


class _ChamberTitleParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.title = ""

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        if tag.lower() != "input":
            return
        values = dict(attrs)
        if values.get("id") == "titoloAssistenza":
            self.title = str(values.get("value") or "").strip()


class _RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
    def redirect_request(
        self,
        req: urllib.request.Request,
        fp: Any,
        code: int,
        msg: str,
        headers: Any,
        newurl: str,
    ) -> urllib.request.Request | None:
        _validate_url(newurl)
        raise SariConnectorError(
            "SARI redirects are not allowed within the two-request operation budget"
        )


@dataclass(frozen=True)
class _Fetched:
    raw: bytes
    content_type: str
    final_url: str


def _validate_url(url: str) -> str:
    parsed = urllib.parse.urlsplit(url)
    if parsed.scheme != "https" or parsed.hostname != SARI_HOST:
        raise SariConnectorError("SARI request or redirect left the exact HTTPS host")
    if parsed.username or parsed.password or parsed.port not in (None, 443):
        raise SariConnectorError(
            "SARI URL must not contain credentials or a custom port"
        )
    if not parsed.path.startswith(SARI_PREFIX):
        raise SariConnectorError("SARI URL left the allowed /sariWeb/ path")
    return url


def _authorization(value: object, *, field: str) -> str:
    text = str(value or "").strip()
    if not AUTHORIZATION_RE.fullmatch(text):
        raise SariConnectorError(
            f"{field} must be a stable 3-120 character authorization reference"
        )
    return text


def _json_payload(raw: bytes, *, context: str) -> dict[str, Any]:
    try:
        value = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise SariConnectorError(f"{context} did not return valid UTF-8 JSON") from exc
    if not isinstance(value, dict):
        raise SariConnectorError(f"{context} did not return a JSON object")
    return value


def _flatten_abstract(value: object) -> str:
    fragments: list[str] = []
    if isinstance(value, list):
        for record in value:
            if not isinstance(record, dict):
                continue
            for texts in record.values():
                if isinstance(texts, list):
                    fragments.extend(normalize_html_text(text) for text in texts)
    return " ".join(fragment for fragment in fragments if fragment).strip()


def _facet_paths(value: object) -> list[str]:
    paths: list[str] = []

    def walk(node: object, parents: list[str]) -> None:
        if not isinstance(node, dict):
            return
        description = normalize_html_text(node.get("description"))
        current = [*parents, description] if description else parents
        children = node.get("children")
        if isinstance(children, list) and children:
            for child in children:
                walk(child, current)
        elif current:
            paths.append(" > ".join(current))

    if isinstance(value, list):
        for item in value:
            walk(item, [])
    return sorted(set(paths))


def normalize_search_result(payload: dict[str, Any], *, limit: int) -> dict[str, Any]:
    """Normalize only candidate metadata; do not infer relevance or applicability."""

    result = payload.get("result")
    if not isinstance(result, dict):
        raise SariConnectorError("SARI search response is missing result")
    documents = result.get("_listdocs")
    if not isinstance(documents, list):
        raise SariConnectorError("SARI search response is missing result._listdocs")
    candidates: list[dict[str, Any]] = []
    for document in documents[:limit]:
        if not isinstance(document, dict):
            continue
        card_id = str(document.get("id_scheda") or document.get("id") or "").strip()
        if not CARD_ID_RE.fullmatch(card_id):
            continue
        candidates.append(
            {
                "card_id": card_id,
                "title": normalize_html_text(document.get("titolo")),
                "updated_at": str(document.get("dt_ultima_modifica") or "").strip()
                or None,
                "content_type": str(document.get("tipo_scheda") or "").strip() or None,
                "abstract": _flatten_abstract(document.get("_abstract")),
                "facet_paths": _facet_paths(document.get("_listfacets")),
                "selection_status": "candidate_requires_human_selection",
            }
        )
    count = result.get("_numDocs")
    return {
        "reported_result_count": count if isinstance(count, int) else len(documents),
        "returned_candidate_count": len(candidates),
        "candidates": candidates,
        "semantic_classification": "not_performed",
    }


def normalize_card(payload: dict[str, Any], *, card_id: str) -> dict[str, Any]:
    """Normalize the official fields of one human-selected SARI card."""

    fields: dict[str, str] = {}
    for key, value in payload.items():
        if isinstance(value, (str, int, float)) and not isinstance(value, bool):
            text = normalize_html_text(value)
            if text:
                fields[str(key)] = text
    title = fields.get("titolo", "")
    if not title:
        raise SariConnectorError("selected SARI card has no title")
    return {
        "card_id": card_id,
        "title": title,
        "fields": fields,
        "applicability_status": "requires_professional_confirmation",
        "semantic_classification": "not_performed",
    }


class SariClient:
    """One in-memory anonymous tenant session with no cookie persistence."""

    def __init__(self, *, timeout_seconds: float = 15.0) -> None:
        self.timeout_seconds = timeout_seconds
        cookie_jar = http.cookiejar.CookieJar()
        self.opener = urllib.request.build_opener(
            urllib.request.HTTPCookieProcessor(cookie_jar),
            _RejectRedirectHandler(),
        )
        self.tenant = ""
        self.chamber_title = ""
        self.request_count = 0

    def _fetch(self, url: str, *, expected_content_type: str) -> _Fetched:
        _validate_url(url)
        if self.request_count >= MAX_REQUESTS_PER_OPERATION:
            raise SariConnectorError(
                f"SARI operation exceeds the {MAX_REQUESTS_PER_OPERATION}-request limit"
            )
        self.request_count += 1
        request = urllib.request.Request(
            url,
            method="GET",
            headers={
                "Accept": expected_content_type,
                "User-Agent": f"{PLUGIN_NAME}/0.1 bounded-read-only",
            },
        )
        try:
            with self.opener.open(request, timeout=self.timeout_seconds) as response:
                final_url = response.geturl()
                _validate_url(final_url)
                content_type = response.headers.get_content_type().lower()
                if content_type != expected_content_type:
                    raise SariConnectorError(
                        f"unexpected SARI content type {content_type!r}; expected {expected_content_type!r}"
                    )
                raw = response.read(MAX_RESPONSE_BYTES + 1)
        except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
            raise SariConnectorError(f"SARI request failed: {exc}") from exc
        if len(raw) > MAX_RESPONSE_BYTES:
            raise SariConnectorError(
                f"SARI response exceeds {MAX_RESPONSE_BYTES} bytes"
            )
        return _Fetched(raw=raw, content_type=content_type, final_url=final_url)

    def initialize_tenant(self, tenant: str, *, expected_chamber: str) -> _Fetched:
        normalized_tenant = str(tenant or "").strip().lower()
        if not TENANT_RE.fullmatch(normalized_tenant):
            raise SariConnectorError("tenant must be one conservative SARI slug")
        expected = " ".join(str(expected_chamber or "").split())
        if len(expected) < 4 or len(expected) > 180:
            raise SariConnectorError(
                "expected chamber title must contain 4-180 characters"
            )
        fetched = self._fetch(
            f"{SARI_ORIGIN}{SARI_PREFIX}{urllib.parse.quote(normalized_tenant)}",
            expected_content_type="text/html",
        )
        parser = _ChamberTitleParser()
        parser.feed(fetched.raw.decode("utf-8", errors="strict"))
        parser.close()
        actual = " ".join(parser.title.split())
        if not actual or expected.casefold() not in actual.casefold():
            raise SariConnectorError(
                "SARI tenant/chamber mismatch; use the official browser directory instead "
                f"(expected {expected!r}, observed {actual!r})"
            )
        self.tenant = normalized_tenant
        self.chamber_title = actual
        return fetched

    def search(self, query: str, *, limit: int) -> _Fetched:
        if not self.tenant:
            raise SariConnectorError("initialize tenant before search")
        params = urllib.parse.urlencode(
            {
                "query": query,
                "start": 0,
                "size": limit,
                "facetnode": "",
                "pageType": "search",
                "userRole": "mypageUD",
            }
        )
        return self._fetch(
            f"{SARI_ORIGIN}{SARI_PREFIX}faq/get/?{params}",
            expected_content_type="application/json",
        )

    def fetch_card(self, card_id: str) -> _Fetched:
        if not self.tenant:
            raise SariConnectorError("initialize tenant before fetching a card")
        if not CARD_ID_RE.fullmatch(card_id):
            raise SariConnectorError("card id contains unsupported characters")
        return self._fetch(
            f"{SARI_ORIGIN}{SARI_PREFIX}card/get_dettaglio/{urllib.parse.quote(card_id)}",
            expected_content_type="application/json",
        )


def _source_manifest(output_dir: Path, *, run_id: str) -> dict[str, Any]:
    path = output_dir / "official_sources.json"
    if path.exists():
        manifest = load_json_object(path)
        if manifest.get("plugin") != PLUGIN_NAME or manifest.get("run_id") != run_id:
            raise SariConnectorError(
                "existing official_sources.json belongs to another run"
            )
        sources = manifest.get("sources")
        if not isinstance(sources, list):
            raise SariConnectorError(
                "existing official_sources.json has invalid sources"
            )
        return manifest
    return {
        "schema_version": "1.0",
        "plugin": PLUGIN_NAME,
        "run_id": run_id,
        "created_at": iso_now(),
        "sources": [],
        "source_count": 0,
    }


def _upsert_source(manifest: dict[str, Any], source: dict[str, Any]) -> None:
    sources = manifest.get("sources")
    if not isinstance(sources, list):
        raise SariConnectorError("official source manifest has invalid sources")
    sources[:] = [
        item for item in sources if item.get("source_id") != source["source_id"]
    ]
    sources.append(source)
    sources.sort(key=lambda item: str(item.get("source_id") or ""))
    manifest["source_count"] = len(sources)
    manifest["updated_at"] = iso_now()


def _record_network_receipt(
    output_dir: Path,
    *,
    run_id: str,
    operation: str,
    tenant: str,
    written_use_authorization_id: str,
    query_sha256: str | None,
) -> None:
    write_private_json(
        output_dir / "sari_network_receipt.json",
        {
            "schema_version": "1.0",
            "plugin": PLUGIN_NAME,
            "run_id": run_id,
            "operation": operation,
            "tenant": tenant,
            "recorded_at": iso_now(),
            "route_selected": True,
            "network_used": True,
            "written_use_authorization_id": written_use_authorization_id,
            "query_sha256": query_sha256,
            "credentials_used": False,
            "cookies_persisted_or_exported": False,
            "login_or_submission_performed": False,
            "request_limit": MAX_REQUESTS_PER_OPERATION,
        },
    )


def _record_completed_connector_use(
    output_dir: Path,
    *,
    run_id: str,
    operation: str,
    tenant: str,
    written_use_authorization_id: str,
    outputs: list[str],
) -> None:
    run_path = output_dir / "run_intake.json"
    if not run_path.exists():
        return
    run = load_json_object(run_path)
    if run.get("plugin") != PLUGIN_NAME or run.get("run_id") != run_id:
        raise SariConnectorError("run_intake.json belongs to another run")
    data_posture = run.get("data_posture")
    if not isinstance(data_posture, dict):
        data_posture = {}
    data_posture.setdefault("local_files_read", [])
    data_posture.setdefault("upload_paths_used", [])
    data_posture.setdefault("hosted_notebook_execution_used", False)
    data_posture.setdefault("remote_sql_execution_used", False)
    connectors = data_posture.get("external_connectors_used")
    if not isinstance(connectors, list):
        connectors = []
    completed_at = iso_now()
    connector_entry = {
        "connector": "authorized_sari_json_read_only",
        "origin": SARI_ORIGIN,
        "tenant": tenant,
        "operation": operation,
        "credentials_used": False,
        "status": "completed",
        "completed_at": completed_at,
    }
    connectors = [
        entry
        for entry in connectors
        if not isinstance(entry, dict)
        or (
            entry.get("connector"),
            entry.get("tenant"),
            entry.get("operation"),
        )
        != (
            connector_entry["connector"],
            tenant,
            operation,
        )
    ]
    connectors.append(connector_entry)
    data_posture["external_connectors_used"] = connectors
    route_entry = {
        "route": "authorized_sari_json_read_only",
        "destination_or_origin": SARI_ORIGIN,
        "payload_category": "generic_public_query_or_selected_card_identifier",
        "network_used": True,
        "access_basis": (
            f"written_use_authorization_id:{written_use_authorization_id}"
        ),
    }
    routes = data_posture.get("external_routes_used")
    if not isinstance(routes, list):
        routes = []
    routes = [
        entry
        for entry in routes
        if not isinstance(entry, dict) or entry.get("route") != route_entry["route"]
    ]
    routes.append(route_entry)
    data_posture["external_routes_used"] = routes
    run["data_posture"] = data_posture
    trace = run.get("execution_trace")
    if not isinstance(trace, list):
        trace = []
    trace.append(
        {
            "step_id": f"sari_connector_completed_{len(trace) + 1}",
            "kind": "external_official_source_read",
            "command": [
                "python",
                "scripts/sari_connector.py",
                operation,
                "--tenant",
                tenant,
            ],
            "execution_location": "external_connector",
            "status": "passed",
            "inputs": [f"{SARI_ORIGIN}{SARI_PREFIX}{tenant}"],
            "outputs": outputs,
        }
    )
    run["execution_trace"] = trace
    write_private_json(run_path, run)


def run_search(
    *,
    output_dir: Path,
    run_id: str,
    tenant: str,
    expected_chamber: str,
    query: str,
    written_use_authorization_id: str,
    limit: int = MAX_RESULTS,
    client: SariClient | None = None,
) -> dict[str, Any]:
    """Run one authorized metadata-only SARI search."""

    run_id = safe_identifier(run_id, field="run_id")
    written_use_authorization_id = _authorization(
        written_use_authorization_id, field="written_use_authorization_id"
    )
    safe_output = ensure_safe_output_dir(output_dir, plugin_root=PLUGIN_ROOT)
    intake_path = safe_output / "case_intake_validated.json"
    direct_identifiers: list[object] = []
    if intake_path.exists():
        intake = load_json_object(intake_path)
        direct_identifiers.append(intake.get("client_reference"))
        identity = intake.get("client_identity")
        if isinstance(identity, dict):
            direct_identifiers.extend(identity.values())
    clean_query = assert_generic_public_query(
        query, direct_identifiers=direct_identifiers
    )
    if not 1 <= limit <= MAX_RESULTS:
        raise SariConnectorError(f"limit must be between 1 and {MAX_RESULTS}")
    active_client = client or SariClient()
    tenant_page = active_client.initialize_tenant(
        tenant, expected_chamber=expected_chamber
    )
    fetched = active_client.search(clean_query, limit=limit)
    payload = _json_payload(fetched.raw, context="SARI search")
    normalized = normalize_search_result(payload, limit=limit)
    retrieved_at = iso_now()
    share_base = f"{SARI_ORIGIN}{SARI_PREFIX}{active_client.tenant}"
    for candidate in normalized["candidates"]:
        candidate["browser_url"] = (
            f"{share_base}?{urllib.parse.urlencode({'apriContenuto': candidate['card_id']})}"
        )
    result = {
        "schema_version": "1.0",
        "plugin": PLUGIN_NAME,
        "run_id": run_id,
        "retrieved_at": retrieved_at,
        "tenant": active_client.tenant,
        "chamber_title": active_client.chamber_title,
        "query": clean_query,
        "query_sha256": sha256_bytes(clean_query.encode("utf-8")),
        "written_use_authorization_id": written_use_authorization_id,
        **normalized,
    }
    result_path = write_private_json(
        safe_output / "sari_search_candidates.json", result
    )
    manifest = _source_manifest(safe_output, run_id=run_id)
    source_id = f"SARI-{active_client.tenant.upper()}-SEARCH"
    _upsert_source(
        manifest,
        {
            "source_id": source_id,
            "source_type": "official_sari_search_metadata",
            "publisher": "InfoCamere / Camera di commercio selezionata",
            "tenant": active_client.tenant,
            "chamber_title": active_client.chamber_title,
            "official_url": share_base,
            "retrieval_endpoint": f"{SARI_ORIGIN}{SARI_PREFIX}faq/get/",
            "retrieved_at": retrieved_at,
            "response_sha256": sha256_bytes(fetched.raw),
            "tenant_page_sha256": sha256_bytes(tenant_page.raw),
            "artifact_path": result_path.name,
            "artifact_sha256": sha256_bytes(result_path.read_bytes()),
            "use_authorization_id": written_use_authorization_id,
            "applicability_status": "candidate_search_only",
        },
    )
    write_private_json(safe_output / "official_sources.json", manifest)
    _record_network_receipt(
        safe_output,
        run_id=run_id,
        operation="search_metadata",
        tenant=active_client.tenant,
        written_use_authorization_id=written_use_authorization_id,
        query_sha256=sha256_bytes(clean_query.encode("utf-8")),
    )
    _record_completed_connector_use(
        safe_output,
        run_id=run_id,
        operation="search",
        tenant=active_client.tenant,
        written_use_authorization_id=written_use_authorization_id,
        outputs=[
            "sari_search_candidates.json",
            "official_sources.json",
            "sari_network_receipt.json",
        ],
    )
    return result


def run_detail(
    *,
    output_dir: Path,
    run_id: str,
    tenant: str,
    expected_chamber: str,
    card_id: str,
    written_use_authorization_id: str,
    client: SariClient | None = None,
) -> dict[str, Any]:
    """Fetch exactly one authorized, human-selected SARI card."""

    run_id = safe_identifier(run_id, field="run_id")
    card_id = str(card_id or "").strip()
    if not CARD_ID_RE.fullmatch(card_id):
        raise SariConnectorError("card id contains unsupported characters")
    written_use_authorization_id = _authorization(
        written_use_authorization_id, field="written_use_authorization_id"
    )
    safe_output = ensure_safe_output_dir(output_dir, plugin_root=PLUGIN_ROOT)
    active_client = client or SariClient()
    tenant_page = active_client.initialize_tenant(
        tenant, expected_chamber=expected_chamber
    )
    fetched = active_client.fetch_card(card_id)
    normalized = normalize_card(
        _json_payload(fetched.raw, context="SARI selected card"), card_id=card_id
    )
    retrieved_at = iso_now()
    browser_url = (
        f"{SARI_ORIGIN}{SARI_PREFIX}{active_client.tenant}?"
        + urllib.parse.urlencode({"apriContenuto": card_id})
    )
    result = {
        "schema_version": "1.0",
        "plugin": PLUGIN_NAME,
        "run_id": run_id,
        "retrieved_at": retrieved_at,
        "tenant": active_client.tenant,
        "chamber_title": active_client.chamber_title,
        "browser_url": browser_url,
        "written_use_authorization_id": written_use_authorization_id,
        **normalized,
    }
    result_path = write_private_json(safe_output / f"sari_card_{card_id}.json", result)
    manifest = _source_manifest(safe_output, run_id=run_id)
    source_id = f"SARI-{active_client.tenant.upper()}-CARD-{card_id}"
    _upsert_source(
        manifest,
        {
            "source_id": source_id,
            "source_type": "official_sari_selected_card",
            "publisher": "InfoCamere / Camera di commercio selezionata",
            "tenant": active_client.tenant,
            "chamber_title": active_client.chamber_title,
            "card_id": card_id,
            "title": normalized["title"],
            "official_url": browser_url,
            "retrieval_endpoint": (
                f"{SARI_ORIGIN}{SARI_PREFIX}card/get_dettaglio/{card_id}"
            ),
            "retrieved_at": retrieved_at,
            "response_sha256": sha256_bytes(fetched.raw),
            "tenant_page_sha256": sha256_bytes(tenant_page.raw),
            "artifact_path": result_path.name,
            "artifact_sha256": sha256_bytes(result_path.read_bytes()),
            "use_authorization_id": written_use_authorization_id,
            "applicability_status": "requires_professional_confirmation",
        },
    )
    write_private_json(safe_output / "official_sources.json", manifest)
    _record_network_receipt(
        safe_output,
        run_id=run_id,
        operation="selected_card_detail",
        tenant=active_client.tenant,
        written_use_authorization_id=written_use_authorization_id,
        query_sha256=None,
    )
    _record_completed_connector_use(
        safe_output,
        run_id=run_id,
        operation="detail",
        tenant=active_client.tenant,
        written_use_authorization_id=written_use_authorization_id,
        outputs=[
            result_path.name,
            "official_sources.json",
            "sari_network_receipt.json",
        ],
    )
    return result


def main(argv: list[str] | None = None) -> int:
    """Run the explicitly authorized direct connector."""

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("operation", choices=("search", "detail"))
    parser.add_argument("--output-dir", type=Path, required=True)
    parser.add_argument("--client-engagement", type=Path, required=True)
    parser.add_argument("--tenant", required=True)
    parser.add_argument("--expected-chamber", required=True)
    parser.add_argument("--written-use-authorization-id", required=True)
    parser.add_argument("--query")
    parser.add_argument("--card-id")
    parser.add_argument("--limit", type=int, default=MAX_RESULTS)
    args = parser.parse_args(argv)
    try:
        context = load_running_case_context(
            args.client_engagement,
            output_dir=args.output_dir,
        )
        for name in ("run_intake.json", "official_sources.json"):
            path = args.output_dir / name
            if path.exists():
                require_case_artifact_run(path, run_id=context["run_id"])
        common = {
            "output_dir": args.output_dir,
            "run_id": context["run_id"],
            "tenant": args.tenant,
            "expected_chamber": args.expected_chamber,
            "written_use_authorization_id": args.written_use_authorization_id,
        }
        if args.operation == "search":
            if not args.query:
                parser.error("search requires --query")
            result = run_search(query=args.query, limit=args.limit, **common)
            LOGGER.info(
                "Stored %s SARI candidates for human selection in %s",
                result["returned_candidate_count"],
                args.output_dir,
            )
        else:
            if not args.card_id:
                parser.error("detail requires --card-id")
            result = run_detail(card_id=args.card_id, **common)
            LOGGER.info("Stored selected SARI card %s", result["card_id"])
    except (AssuranceContractError, OSError, SariConnectorError, ValueError) as exc:
        LOGGER.error("SARI_CONNECTOR_BLOCKED: %s", exc)
        return 2
    return 0


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    raise SystemExit(main())

SHA-256: c0c44015c6ac8c56294517b99008501dacfc3c6cc44cf7631f87901e23b6aa78