"""Predefined, read-only Firebird catalog metadata collection. Mission 00D: the concrete reader consumed here is ``gti_discovery.catalog.FirebirdCatalog`` -- a closed API with no ``execute(sql)``-shaped method at all. ``collect_metadata`` only ever calls the narrow, named operations declared on ``ReadOnlyCatalogReader`` below; it has no path to send arbitrary SQL even if a caller wanted it to. The private immutable catalog operation map contains fixed, project-owned strings that ``FirebirdCatalog`` alone executes -- they never depend on the offline lexical checker in ``gti_discovery.read_only``, which is no longer part of the runtime path (see that module's docstring). """ from collections.abc import Mapping import json from types import MappingProxyType from typing import Any _CATALOG_QUERIES = MappingProxyType({ "database": "SELECT MON$DATABASE_NAME, MON$ODS_MAJOR, MON$ODS_MINOR FROM MON$DATABASE", "relations": "SELECT RDB$RELATION_NAME AS RELATION_NAME, RDB$RELATION_ID AS RELATION_ID, RDB$SYSTEM_FLAG AS SYSTEM_FLAG FROM RDB$RELATIONS", "fields": "SELECT RF.RDB$RELATION_NAME AS RELATION_NAME, RF.RDB$FIELD_NAME AS FIELD_NAME, RF.RDB$FIELD_POSITION AS FIELD_POSITION, RF.RDB$NULL_FLAG AS NULL_FLAG, F.RDB$FIELD_TYPE AS FIELD_TYPE, F.RDB$FIELD_SUB_TYPE AS FIELD_SUB_TYPE, F.RDB$FIELD_LENGTH AS FIELD_LENGTH, F.RDB$FIELD_PRECISION AS FIELD_PRECISION, F.RDB$FIELD_SCALE AS FIELD_SCALE, T.RDB$TYPE_NAME AS TYPE_NAME FROM RDB$RELATION_FIELDS RF JOIN RDB$FIELDS F ON F.RDB$FIELD_NAME = RF.RDB$FIELD_SOURCE LEFT JOIN RDB$TYPES T ON T.RDB$TYPE = F.RDB$FIELD_TYPE AND T.RDB$FIELD_NAME = 'RDB$FIELD_TYPE'", "keys": "SELECT RC.RDB$CONSTRAINT_NAME AS CONSTRAINT_NAME, RC.RDB$RELATION_NAME AS RELATION_NAME, RC.RDB$CONSTRAINT_TYPE AS CONSTRAINT_TYPE, RC.RDB$INDEX_NAME AS INDEX_NAME, S.RDB$FIELD_NAME AS FIELD_NAME, S.RDB$FIELD_POSITION AS FIELD_POSITION FROM RDB$RELATION_CONSTRAINTS RC LEFT JOIN RDB$INDEX_SEGMENTS S ON S.RDB$INDEX_NAME = RC.RDB$INDEX_NAME", "indexes": "SELECT I.RDB$INDEX_NAME AS INDEX_NAME, I.RDB$RELATION_NAME AS RELATION_NAME, I.RDB$UNIQUE_FLAG AS UNIQUE_FLAG, I.RDB$INDEX_TYPE AS INDEX_TYPE, S.RDB$FIELD_NAME AS FIELD_NAME, S.RDB$FIELD_POSITION AS FIELD_POSITION FROM RDB$INDICES I LEFT JOIN RDB$INDEX_SEGMENTS S ON S.RDB$INDEX_NAME = I.RDB$INDEX_NAME", "foreign_keys": "SELECT C.RDB$CONSTRAINT_NAME AS CONSTRAINT_NAME, C.RDB$RELATION_NAME AS CHILD_RELATION_NAME, C.RDB$INDEX_NAME AS CHILD_INDEX_NAME, CS.RDB$FIELD_NAME AS CHILD_FIELD_NAME, CS.RDB$FIELD_POSITION AS CHILD_FIELD_POSITION, U.RDB$RELATION_NAME AS REFERENCED_RELATION_NAME, U.RDB$INDEX_NAME AS REFERENCED_INDEX_NAME, US.RDB$FIELD_NAME AS REFERENCED_FIELD_NAME, US.RDB$FIELD_POSITION AS REFERENCED_FIELD_POSITION, R.RDB$UPDATE_RULE AS UPDATE_RULE, R.RDB$DELETE_RULE AS DELETE_RULE FROM RDB$REF_CONSTRAINTS R JOIN RDB$RELATION_CONSTRAINTS C ON C.RDB$CONSTRAINT_NAME = R.RDB$CONSTRAINT_NAME JOIN RDB$RELATION_CONSTRAINTS U ON U.RDB$CONSTRAINT_NAME = R.RDB$CONST_NAME_UQ LEFT JOIN RDB$INDEX_SEGMENTS CS ON CS.RDB$INDEX_NAME = C.RDB$INDEX_NAME LEFT JOIN RDB$INDEX_SEGMENTS US ON US.RDB$INDEX_NAME = U.RDB$INDEX_NAME AND US.RDB$FIELD_POSITION = CS.RDB$FIELD_POSITION", "triggers": "SELECT RDB$TRIGGER_NAME AS TRIGGER_NAME, RDB$RELATION_NAME AS RELATION_NAME, RDB$TRIGGER_TYPE AS TRIGGER_TYPE, RDB$TRIGGER_SEQUENCE AS TRIGGER_SEQUENCE, RDB$TRIGGER_INACTIVE AS TRIGGER_INACTIVE FROM RDB$TRIGGERS", "procedures": "SELECT RDB$PROCEDURE_NAME AS PROCEDURE_NAME, RDB$PROCEDURE_ID AS PROCEDURE_ID FROM RDB$PROCEDURES", "procedure_parameters": "SELECT RDB$PROCEDURE_NAME AS PROCEDURE_NAME, RDB$PARAMETER_NAME AS PARAMETER_NAME, RDB$PARAMETER_NUMBER AS PARAMETER_NUMBER, RDB$PARAMETER_TYPE AS PARAMETER_TYPE, RDB$FIELD_TYPE AS FIELD_TYPE, RDB$FIELD_SUB_TYPE AS FIELD_SUB_TYPE, RDB$FIELD_LENGTH AS FIELD_LENGTH, RDB$FIELD_PRECISION AS FIELD_PRECISION, RDB$FIELD_SCALE AS FIELD_SCALE FROM RDB$PROCEDURE_PARAMETERS", "packages": "SELECT RDB$PACKAGE_NAME AS PACKAGE_NAME, RDB$PACKAGE_ID AS PACKAGE_ID FROM RDB$PACKAGES", "generators": "SELECT RDB$GENERATOR_NAME AS GENERATOR_NAME, RDB$GENERATOR_ID AS GENERATOR_ID, RDB$SYSTEM_FLAG AS SYSTEM_FLAG FROM RDB$GENERATORS", "dependencies": "SELECT RDB$DEPENDENT_NAME AS DEPENDENT_NAME, RDB$DEPENDED_ON_NAME AS DEPENDED_ON_NAME, RDB$DEPENDENT_TYPE AS DEPENDENT_TYPE, RDB$DEPENDED_ON_TYPE AS DEPENDED_ON_TYPE, RDB$FIELD_NAME AS FIELD_NAME FROM RDB$DEPENDENCIES", "monitoring": "SELECT MON$ATTACHMENT_ID AS ATTACHMENT_ID, MON$SERVER_PID AS SERVER_PID, MON$STATE AS STATE, MON$TIMESTAMP AS TIMESTAMP FROM MON$ATTACHMENTS", }) _SENSITIVE_WORDS = ( "PASSWORD", "SECRET", "TOKEN", "CREDENTIAL", "AUTH", "BLOB", "SOURCE", "DEFAULT", "BODY", ) _MAX_SAFE_STRING = 1024 _SAMPLE_SENSITIVE_WORDS = _SENSITIVE_WORDS + ( "CUSTOMER", "CLIENT", "PRICE", "AMOUNT", "COST", "EMAIL", "PHONE", "ADDRESS", "TAX", "CPF", "CNPJ", ) _FIXTURE_REGISTRATION_TOKEN = object() _APPROVED_FIXTURE_TYPES = MappingProxyType({}) def _approved_fixture_reader(reader_type: type | None = None, *, token: object) -> type: """Register one exact fixture type through the private test capability.""" if token is not _FIXTURE_REGISTRATION_TOKEN: raise TypeError("fixture registration is private") def register(candidate: type) -> type: global _APPROVED_FIXTURE_TYPES if not isinstance(candidate, type): raise TypeError("fixture registration requires a type") _APPROVED_FIXTURE_TYPES = MappingProxyType( {**_APPROVED_FIXTURE_TYPES, candidate: True} ) return candidate return register(reader_type) if reader_type is not None else register class ReadOnlyCatalogReader: """Approved structured reader contract used by the collector.""" __slots__ = () def get_database_info(self): return self.list_database() def list_database(self): raise NotImplementedError def list_relations(self): raise NotImplementedError def get_relation_columns(self, relation): return [row for row in self.list_relation_fields() if row.get("RELATION_NAME", "").strip().upper() == str(relation).strip().upper()] def get_primary_keys(self, relation): return [row for row in self.list_keys() if row.get("RELATION_NAME", "").strip().upper() == str(relation).strip().upper()] def get_foreign_keys(self, relation): known = str(relation).strip().upper() return [row for row in self.list_foreign_keys() if known in { str(row.get("CHILD_RELATION_NAME", "")).strip().upper(), str(row.get("REFERENCED_RELATION_NAME", "")).strip().upper(), }] def get_indices(self, relation): return [row for row in self.list_indexes() if row.get("RELATION_NAME", "").strip().upper() == str(relation).strip().upper()] def get_triggers(self): return self.list_triggers() def get_procedures(self): return self.list_procedures() def get_procedure_parameters(self): return self.list_procedure_parameters() def get_packages(self): return self.list_packages() def get_generators(self): return self.list_generators() def get_dependencies(self): return self.list_dependencies() def get_monitoring(self): return self.list_monitoring() def _safe_value(value: Any) -> Any: if isinstance(value, (bytes, bytearray, memoryview)): return "" if isinstance(value, Mapping): return { str(key): _safe_value(item) for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) if not any(word in str(key).upper() for word in _SENSITIVE_WORDS) } if isinstance(value, (list, tuple)): return [_safe_value(item) for item in value] if isinstance(value, str): upper_value = value.upper() if any(word in upper_value for word in _SENSITIVE_WORDS): return "" return value if len(value) <= _MAX_SAFE_STRING else "" if isinstance(value, (int, float, bool)) or value is None: return value return "" def _safe_sample_rows(rows: Any) -> list[dict[str, Any]]: """Keep sampled evidence flat, scalar, bounded, and mapping-relevant.""" sanitized = [] for row in rows if isinstance(rows, list) else []: if not isinstance(row, Mapping): continue safe_row = {} for key, value in row.items(): name = str(key) upper_name = name.upper() if "BLOB" in upper_name: safe_row[name] = "" continue if any(word in upper_name for word in _SAMPLE_SENSITIVE_WORDS): continue if isinstance(value, (str, int, float, bool)) or value is None: safe_row[name] = _safe_value(value) sanitized.append(safe_row) return sanitized def _row_sort_key(row: Any) -> str: return json.dumps(_safe_value(row), ensure_ascii=True, sort_keys=True, separators=(",", ":")) def _sort_rows(rows: Any) -> Any: if isinstance(rows, list): return sorted(rows, key=_row_sort_key) return rows def _is_optional_unsupported(name: str, error: Exception) -> bool: if isinstance(error, PermissionError): return False if name not in {"packages", "procedure_parameters", "generators", "dependencies", "monitoring"}: return False if isinstance(error, AttributeError): return True message = str(error).lower() if any( marker in message for marker in ( "permission", "privilege", "access denied", "not authorized", "unauthorized", "authorization", ) ): return False if isinstance(error, NotImplementedError): return True return any( marker in message for marker in ( "table unknown", "column unknown", "does not exist", "not found", "unsupported", "not supported", "not implemented", "capability unavailable", ) ) def _fetch_rows(result: Any) -> list[Any]: description = getattr(result, "description", None) if hasattr(result, "fetchall"): result = result.fetchall() rows = list(result) if result is not None else [] if description: names = [str(column[0]) for column in description] return [ dict(zip(names, row)) if isinstance(row, tuple) else row for row in rows ] return rows def _optional_operation(reader: ReadOnlyCatalogReader, method_name: str): try: operation = getattr(reader, method_name) except AttributeError: return None if method_name.startswith("get_") and getattr(type(reader), method_name, None) is getattr(ReadOnlyCatalogReader, method_name, None): try: getattr(reader, "list_" + method_name.removeprefix("get_")) except AttributeError: return None return operation def collect_metadata(reader: ReadOnlyCatalogReader) -> dict: """Collect catalog rows through the approved structured reader only.""" from .catalog import StructuredFirebirdReader if not isinstance(reader, ReadOnlyCatalogReader): raise TypeError("reader must be ReadOnlyCatalogReader") if any(callable(getattr(reader, name, None)) for name in ("execute", "query", "raw_execute", "run_sql")): raise TypeError("reader must not expose raw execution") is_approved_fixture = type(reader) in _APPROVED_FIXTURE_TYPES structured_methods = ( "get_database_info", "get_relation_columns", "get_primary_keys", "get_foreign_keys", "get_indices", "get_triggers", "get_procedures", "get_procedure_parameters", "get_packages", "get_generators", "get_dependencies", "get_monitoring", ) approved_implementations = { getattr(ReadOnlyCatalogReader, name) for name in structured_methods } | { getattr(StructuredFirebirdReader, name) for name in structured_methods } if not is_approved_fixture and ( type(reader) is not StructuredFirebirdReader or any(getattr(type(reader), name, None) not in approved_implementations for name in structured_methods) ): raise TypeError("reader must use the sealed structured operations") metadata = {} errors = [] unavailable = ["events"] relations = [] def run(name, operation, *, optional=False): try: return operation() except Exception as error: kind = "optional_unsupported" if optional and _is_optional_unsupported(name, error) else "query_error" errors.append({"query": name, "kind": kind, "error": type(error).__name__}) if kind == "optional_unsupported": unavailable.append(name) return [] metadata["database"] = run("database", reader.get_database_info) relations = run("relations", reader.list_relations) metadata["relations"] = relations metadata["fields"] = [] def extend_unique(target, rows): for row in rows: if row not in target: target.append(row) for relation_row in relations: if not isinstance(relation_row, Mapping): continue relation = relation_row.get("RELATION_NAME", relation_row.get("relation_name")) if relation is None: continue extend_unique(metadata["fields"], run("fields", lambda relation=relation: reader.get_relation_columns(relation))) metadata["keys"] = [] metadata["foreign_keys"] = [] metadata["indexes"] = [] for relation_row in relations: if not isinstance(relation_row, Mapping): continue relation = relation_row.get("RELATION_NAME", relation_row.get("relation_name")) if relation is None: continue extend_unique(metadata["keys"], run("keys", lambda relation=relation: reader.get_primary_keys(relation))) extend_unique(metadata["foreign_keys"], run("foreign_keys", lambda relation=relation: reader.get_foreign_keys(relation))) extend_unique(metadata["indexes"], run("indexes", lambda relation=relation: reader.get_indices(relation))) operations = { "triggers": (reader.get_triggers, False), "procedures": (reader.get_procedures, False), "procedure_parameters": (_optional_operation(reader, "get_procedure_parameters"), True), "packages": (_optional_operation(reader, "get_packages"), True), "generators": (_optional_operation(reader, "get_generators"), True), "dependencies": (_optional_operation(reader, "get_dependencies"), True), "monitoring": (_optional_operation(reader, "get_monitoring"), True), } for name, (operation, optional) in operations.items(): if operation is None or not callable(operation): metadata[name] = [] unavailable.append(name) continue metadata[name] = run(name, operation, optional=optional) for name, rows in list(metadata.items()): metadata[name] = _safe_value(_sort_rows(rows)) metadata["events"] = [] metadata["unavailable"] = sorted(set(unavailable)) metadata["errors"] = sorted((_safe_value(error) for error in errors), key=_row_sort_key) metadata["incomplete"] = bool(errors or metadata["unavailable"]) return metadata