chore: establish project baseline
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""Read-only Firebird discovery utilities.
|
||||
|
||||
The lexical SQL validator is intentionally not imported here. It is an
|
||||
offline-only diagnostic utility in ``gti_discovery.read_only`` and has no
|
||||
runtime security authority.
|
||||
"""
|
||||
|
||||
from .firebird_connection import (
|
||||
AUTHORIZED_DATABASE_PATH,
|
||||
connect_authorized_read_only,
|
||||
get_database_identity,
|
||||
validate_authorized_database_identity,
|
||||
validate_authorized_database_path,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AUTHORIZED_DATABASE_PATH",
|
||||
"connect_authorized_read_only",
|
||||
"get_database_identity",
|
||||
"validate_authorized_database_identity",
|
||||
"validate_authorized_database_path",
|
||||
]
|
||||
@@ -0,0 +1,287 @@
|
||||
"""The closed, read-only Firebird catalog API (Mission 00D redesign).
|
||||
|
||||
This module is the ONLY place that ever builds SQL text and sends it to
|
||||
the Firebird driver for real. It exposes no ``execute(sql)``, ``query(sql)``
|
||||
or equivalent to callers -- ``FirebirdCatalog`` only offers narrow,
|
||||
pre-defined operations (``get_database_info``, ``list_relations``,
|
||||
``get_relation_columns``, ...), so a caller can never supply SQL, a FROM
|
||||
clause, a WHERE clause, a procedure name to execute, or any other
|
||||
arbitrary expression.
|
||||
|
||||
Two kinds of SQL exist in this module:
|
||||
|
||||
1. Fixed, module-owned catalog queries (``_QUERIES`` below) against
|
||||
``RDB$...``/``MON$...`` system tables. These never contain caller
|
||||
input -- they are plain string literals written by this project --
|
||||
so a lexical "is this SQL safe" check is unnecessary and is
|
||||
deliberately NOT applied to them (see ``gti_discovery.read_only``'s
|
||||
module docstring for why that check is not trustworthy as a
|
||||
boundary anyway). They may freely use CAST, COUNT, SUM, JOIN, or any
|
||||
other legitimate Firebird SQL.
|
||||
|
||||
2. Exactly one dynamic query, built by ``sample_relation_rows``. Its
|
||||
only variable parts are a relation name and column names, and both
|
||||
are required to have been produced by ``list_relations()`` /
|
||||
``get_relation_columns()`` of THIS SAME catalog instance in THIS
|
||||
SAME session first (see ``_KnownCatalog`` below) -- never taken from
|
||||
raw caller text. Firebird identifier quoting is applied, and the row
|
||||
limit is a validated, hard-capped integer embedded as a literal
|
||||
(ints cannot carry SQL syntax). Nothing about this query depends on
|
||||
``gti_discovery.read_only``.
|
||||
|
||||
The exact adapter type must be present in the private approved-adapter
|
||||
registry before construction and must declare database-level read-only
|
||||
enforcement. The handle is then held privately; ``__getattribute__``
|
||||
refuses to hand it back through normal attribute access, so a caller
|
||||
holding a ``FirebirdCatalog`` has no path to raw ``execute``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from .collector import ReadOnlyCatalogReader, _fetch_rows, _safe_sample_rows
|
||||
from . import firebird_queries as _firebird_queries
|
||||
from .firebird_queries import _SamplingCapability, _execute_catalog
|
||||
from .identifiers import quote_firebird_identifier
|
||||
|
||||
MAX_SAMPLE_ROWS = 50
|
||||
_quote_identifier = quote_firebird_identifier
|
||||
|
||||
|
||||
def _freeze_snapshot(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return MappingProxyType({deepcopy(key): _freeze_snapshot(item) for key, item in value.items()})
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(_freeze_snapshot(item) for item in value)
|
||||
return deepcopy(value)
|
||||
|
||||
|
||||
def _freeze_catalog_rows(rows: list[Any]) -> tuple[Any, ...]:
|
||||
return tuple(_freeze_snapshot(row) for row in rows)
|
||||
|
||||
|
||||
class StructuredFirebirdReader(ReadOnlyCatalogReader):
|
||||
"""The only read-only surface handed to collectors/consumers.
|
||||
|
||||
Constructed only by ``gti_discovery.firebird_connection`` from a
|
||||
driver adapter that has already *proven* database-level read-only
|
||||
enforcement (``adapter.read_only is True``) and is present in the
|
||||
private exact-type approval registry. No public constructor path accepts
|
||||
an unapproved or unproven adapter.
|
||||
"""
|
||||
|
||||
__slots__ = ("_handle", "_relations_cache", "_fields_cache")
|
||||
|
||||
def __init__(self, handle: Any):
|
||||
if type(handle) not in _firebird_queries._APPROVED_ADAPTER_TYPES:
|
||||
raise TypeError("adapter is not an approved implementation")
|
||||
if callable(handle) or not callable(getattr(handle, "execute", None)):
|
||||
raise TypeError("adapter must expose an execute method")
|
||||
if getattr(handle, "read_only", None) is not True:
|
||||
raise ValueError("handle must declare database-level read-only enforcement")
|
||||
object.__setattr__(self, "_handle", handle)
|
||||
object.__setattr__(self, "_relations_cache", None)
|
||||
object.__setattr__(self, "_fields_cache", None)
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
if name in ("_handle",):
|
||||
raise AttributeError(name)
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
raise AttributeError("FirebirdCatalog is immutable outside __init__")
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
handle = object.__getattribute__(self, "_handle")
|
||||
close = getattr(handle, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
def _run_fixed_catalog(self, operation_key: str) -> list[Any]:
|
||||
if operation_key not in ("database", "relations", "fields", "keys", "indexes", "foreign_keys", "triggers", "procedures", "procedure_parameters", "packages", "generators", "dependencies", "monitoring"):
|
||||
raise ValueError("operation is not a fixed catalog operation")
|
||||
handle = object.__getattribute__(self, "_handle")
|
||||
return _fetch_rows(_execute_catalog(handle, operation_key))
|
||||
|
||||
# -- bulk catalog reads (fixed, project-owned queries) --------------
|
||||
|
||||
def get_database_info(self):
|
||||
return self._run_fixed_catalog("database")
|
||||
|
||||
def list_relations(self):
|
||||
rows = _freeze_catalog_rows(self._run_fixed_catalog("relations"))
|
||||
object.__setattr__(self, "_relations_cache", rows)
|
||||
return rows
|
||||
|
||||
def list_relation_fields(self):
|
||||
rows = _freeze_catalog_rows(self._run_fixed_catalog("fields"))
|
||||
object.__setattr__(self, "_fields_cache", rows)
|
||||
return rows
|
||||
|
||||
def list_keys(self):
|
||||
return self._run_fixed_catalog("keys")
|
||||
|
||||
def list_indexes(self):
|
||||
return self._run_fixed_catalog("indexes")
|
||||
|
||||
def list_foreign_keys(self):
|
||||
return self._run_fixed_catalog("foreign_keys")
|
||||
|
||||
def list_triggers(self):
|
||||
return self._run_fixed_catalog("triggers")
|
||||
|
||||
def list_procedures(self):
|
||||
return self._run_fixed_catalog("procedures")
|
||||
|
||||
def list_procedure_parameters(self):
|
||||
return self._run_fixed_catalog("procedure_parameters")
|
||||
|
||||
def list_packages(self):
|
||||
return self._run_fixed_catalog("packages")
|
||||
|
||||
def list_generators(self):
|
||||
return self._run_fixed_catalog("generators")
|
||||
|
||||
def list_dependencies(self):
|
||||
return self._run_fixed_catalog("dependencies")
|
||||
|
||||
def list_monitoring(self):
|
||||
return self._run_fixed_catalog("monitoring")
|
||||
|
||||
# -- relation-scoped convenience reads (pure Python filters over ----
|
||||
# -- the same bulk, project-owned queries above -- no new SQL) ------
|
||||
|
||||
def _known_relation_names(self) -> set[str]:
|
||||
cached = object.__getattribute__(self, "_relations_cache")
|
||||
rows = cached if cached is not None else self.list_relations()
|
||||
return {str(row.get("RELATION_NAME", "")).strip().upper() for row in rows}
|
||||
|
||||
def _require_known_relation(self, relation: str) -> str:
|
||||
"""Phase 4: a relation name is only usable if it was itself
|
||||
returned by RDB$RELATIONS first -- never trusted as free text.
|
||||
This structurally excludes procedures/UDFs/expressions, since
|
||||
RDB$RELATIONS only lists real tables and views."""
|
||||
if not isinstance(relation, str) or not relation.strip():
|
||||
raise ValueError("relation must be a non-empty string")
|
||||
candidate = relation.strip().upper()
|
||||
if candidate not in self._known_relation_names():
|
||||
raise ValueError(f"unknown relation: {relation!r} was not returned by list_relations()")
|
||||
rows = object.__getattribute__(self, "_relations_cache")
|
||||
for row in rows or ():
|
||||
issued = str(row.get("RELATION_NAME", "")).strip()
|
||||
if issued.upper() == candidate:
|
||||
return issued
|
||||
raise ValueError(f"unknown relation: {relation!r} was not returned by list_relations()")
|
||||
|
||||
def get_relation_columns(self, relation: str) -> tuple[dict, ...]:
|
||||
known = self._require_known_relation(relation)
|
||||
cached = object.__getattribute__(self, "_fields_cache")
|
||||
rows = cached if cached is not None else self.list_relation_fields()
|
||||
return tuple(row for row in rows if str(row.get("RELATION_NAME", "")).strip().upper() == known.upper())
|
||||
|
||||
def get_primary_keys(self, relation: str) -> list[dict]:
|
||||
known = self._require_known_relation(relation)
|
||||
return [
|
||||
row for row in self.list_keys()
|
||||
if str(row.get("RELATION_NAME", "")).strip().upper() == known
|
||||
and "PRIMARY" in str(row.get("CONSTRAINT_TYPE", "")).upper()
|
||||
]
|
||||
|
||||
def get_foreign_keys(self, relation: str) -> list[dict]:
|
||||
known = self._require_known_relation(relation)
|
||||
return [
|
||||
row for row in self.list_foreign_keys()
|
||||
if str(row.get("CHILD_RELATION_NAME", "")).strip().upper() == known
|
||||
or str(row.get("REFERENCED_RELATION_NAME", "")).strip().upper() == known
|
||||
]
|
||||
|
||||
def get_indices(self, relation: str) -> list[dict]:
|
||||
known = self._require_known_relation(relation)
|
||||
return [row for row in self.list_indexes() if str(row.get("RELATION_NAME", "")).strip().upper() == known]
|
||||
|
||||
def get_triggers(self, relation: str | None = None) -> list[dict]:
|
||||
rows = self.list_triggers()
|
||||
if relation is None:
|
||||
return rows
|
||||
known = self._require_known_relation(relation)
|
||||
return [row for row in rows if str(row.get("RELATION_NAME", "")).strip().upper() == known]
|
||||
|
||||
def get_procedures(self) -> list[dict]:
|
||||
"""Metadata only -- procedures are inventoried, never executed
|
||||
(Mission 00D Phase 5). There is no operation anywhere in this
|
||||
class that runs a procedure or a selectable stored procedure."""
|
||||
return self.list_procedures()
|
||||
|
||||
def get_procedure_parameters(self) -> list[dict]:
|
||||
return self.list_procedure_parameters()
|
||||
|
||||
def get_packages(self) -> list[dict]:
|
||||
return self.list_packages()
|
||||
|
||||
def get_generators(self) -> list[dict]:
|
||||
return self.list_generators()
|
||||
|
||||
def get_dependencies(self) -> list[dict]:
|
||||
return self.list_dependencies()
|
||||
|
||||
def get_monitoring(self) -> list[dict]:
|
||||
return self.list_monitoring()
|
||||
|
||||
# -- the one dynamic operation: bounded, catalog-validated sampling -
|
||||
|
||||
def sample_relation_rows(self, relation: str, columns: list[str], limit: int) -> list[dict]:
|
||||
"""Sample rows from a real table/view, never a procedure/UDF/expression.
|
||||
|
||||
Every safety property below is structural, not textual:
|
||||
- ``relation`` must already appear in ``list_relations()`` (so it is
|
||||
a real table/view -- RDB$RELATIONS never lists procedures/UDFs).
|
||||
- each of ``columns`` must already appear in
|
||||
``get_relation_columns(relation)`` for that exact relation.
|
||||
- ``limit`` must be a plain, non-bool ``int`` within
|
||||
``1..MAX_SAMPLE_ROWS`` and is embedded only as a literal integer
|
||||
(an int value cannot carry SQL syntax).
|
||||
- identifiers are Firebird double-quoted with embedded quotes
|
||||
doubled before being placed in the SQL text.
|
||||
"""
|
||||
known_relation = self._require_known_relation(relation)
|
||||
if not isinstance(columns, (list, tuple)) or not columns:
|
||||
raise ValueError("columns must be a non-empty list of column names")
|
||||
available = {
|
||||
str(row.get("FIELD_NAME", "")).strip().upper(): str(row.get("FIELD_NAME", "")).strip()
|
||||
for row in self.get_relation_columns(known_relation)
|
||||
}
|
||||
validated_columns: list[str] = []
|
||||
for column in columns:
|
||||
if not isinstance(column, str) or not column.strip():
|
||||
raise ValueError("column names must be non-empty strings")
|
||||
candidate = column.strip().upper()
|
||||
if candidate not in available:
|
||||
raise ValueError(f"unknown column {column!r} for relation {relation!r}")
|
||||
validated_columns.append(available[candidate])
|
||||
if type(limit) is not int:
|
||||
raise ValueError("limit must be an int")
|
||||
if not (1 <= limit <= MAX_SAMPLE_ROWS):
|
||||
raise ValueError(f"limit must be between 1 and {MAX_SAMPLE_ROWS}")
|
||||
|
||||
return _safe_sample_rows(
|
||||
_fetch_rows(_execute_catalog(
|
||||
object.__getattribute__(self, "_handle"),
|
||||
"sample_relation",
|
||||
(known_relation, tuple(validated_columns), limit),
|
||||
capability=_SamplingCapability(
|
||||
object.__getattribute__(self, "_relations_cache"),
|
||||
object.__getattribute__(self, "_fields_cache"),
|
||||
),
|
||||
))
|
||||
)
|
||||
|
||||
|
||||
FirebirdCatalog = StructuredFirebirdReader
|
||||
@@ -0,0 +1,333 @@
|
||||
"""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 "<redacted>"
|
||||
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 "<redacted>"
|
||||
return value if len(value) <= _MAX_SAFE_STRING else "<redacted>"
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return value
|
||||
return "<redacted>"
|
||||
|
||||
|
||||
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] = "<redacted>"
|
||||
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
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Narrow, sanitized company context discovery."""
|
||||
|
||||
|
||||
def collect_company_context(reader):
|
||||
"""Read only the catalog-approved company UF/regime projection; never CNPJ."""
|
||||
method = getattr(reader, "company_context_rows", None)
|
||||
if not callable(method):
|
||||
raise TypeError("reader must expose company_context_rows")
|
||||
rows = method()
|
||||
if not isinstance(rows, list) or not rows or any(not isinstance(row, dict) for row in rows):
|
||||
raise ValueError("company context is unavailable")
|
||||
ufs = {str(row.get("UF", "")).strip().upper() for row in rows}
|
||||
regimes = {str(row.get("REGIME", "")).strip() for row in rows}
|
||||
if len(ufs) != 1 or len(regimes) != 1 or not next(iter(ufs)) or not next(iter(regimes)):
|
||||
raise ValueError("company context is ambiguous")
|
||||
return {"uf": next(iter(ufs)), "regime": next(iter(regimes)), "cnpj": "REDACTED", "record_count": len(rows)}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Canonical schema fingerprints and conservative classification."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_SUPPORTED = "SCHEMA_SUPPORTED"
|
||||
SCHEMA_CHANGED = "SCHEMA_CHANGED"
|
||||
SCHEMA_UNKNOWN = "SCHEMA_UNKNOWN"
|
||||
_SHA256 = re.compile(r"\A[0-9a-f]{64}\Z")
|
||||
_REDACTED_WORDS = ("PASSWORD", "SECRET", "TOKEN", "CREDENTIAL", "AUTH", "BLOB", "SOURCE", "DEFAULT", "BODY")
|
||||
_UNORDERED_COLLECTIONS = {"tables", "relations", "fields", "keys", "indexes", "foreign_keys", "triggers", "procedures", "procedure_parameters", "packages", "generators", "dependencies", "monitoring", "events"}
|
||||
_POSITIONAL_COLLECTIONS = {"columns", "segments", "field_order", "positions"}
|
||||
|
||||
|
||||
def _canonical(value: Any, key: str | None = None) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(name): _canonical(item, str(name).lower())
|
||||
for name, item in sorted(value.items(), key=lambda pair: str(pair[0]))
|
||||
if not any(word in str(name).upper() for word in _REDACTED_WORDS)
|
||||
}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
items = [_canonical(item) for item in value]
|
||||
if key in _UNORDERED_COLLECTIONS and key not in _POSITIONAL_COLLECTIONS:
|
||||
return sorted(items, key=lambda item: json.dumps(item, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
||||
return items
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return "<redacted>"
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value if not isinstance(value, str) or len(value) <= 1024 else "<redacted>"
|
||||
return "<redacted>"
|
||||
|
||||
|
||||
def build_fingerprint(metadata: dict) -> str:
|
||||
"""Return SHA-256 of recursively canonicalized relevant metadata."""
|
||||
payload = json.dumps(_canonical(metadata), ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def classify_schema(metadata: dict, expected_fingerprints, *, mapping_ambiguous: bool = False) -> str:
|
||||
"""Classify metadata against a validated allowlist of expected fingerprints."""
|
||||
if mapping_ambiguous:
|
||||
return SCHEMA_UNKNOWN
|
||||
if metadata.get("incomplete") or metadata.get("errors") or metadata.get("unavailable"):
|
||||
return SCHEMA_UNKNOWN
|
||||
try:
|
||||
allowlist = (
|
||||
{expected_fingerprints}
|
||||
if isinstance(expected_fingerprints, str)
|
||||
else set(expected_fingerprints)
|
||||
)
|
||||
except Exception:
|
||||
return SCHEMA_UNKNOWN
|
||||
if not allowlist or not all(isinstance(item, str) and _SHA256.fullmatch(item) for item in allowlist):
|
||||
return SCHEMA_UNKNOWN
|
||||
return SCHEMA_SUPPORTED if build_fingerprint(metadata) in allowlist else SCHEMA_CHANGED
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Authorized, fail-closed Firebird connection boundary."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from .catalog import FirebirdCatalog
|
||||
from .firebird_queries import _ADAPTER_REGISTRATION_TOKEN, _register_approved_adapter
|
||||
|
||||
|
||||
AUTHORIZED_DATABASE_PATH = Path(
|
||||
r"D:\PROJETOS\GTISISTEMAS\Projeto\TESTESOFICIAIS.FDB"
|
||||
)
|
||||
|
||||
|
||||
class _FirebirdAdapter:
|
||||
"""Sealed adapter implementation used by the authorized connection."""
|
||||
|
||||
__slots__ = ("_connection", "_read_only_evidence", "_seal")
|
||||
|
||||
def __init__(self, connection: Any, read_only_evidence: dict[str, Any], seal: object):
|
||||
if seal is not _ADAPTER_CREATION_TOKEN:
|
||||
raise TypeError("adapter construction is private")
|
||||
self._connection = connection
|
||||
self._read_only_evidence = MappingProxyType(dict(read_only_evidence))
|
||||
self._seal = seal
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return self.read_only_evidence.get("accepted") is True
|
||||
|
||||
@property
|
||||
def read_only_evidence(self):
|
||||
return self._read_only_evidence
|
||||
|
||||
def execute(self, sql: str, params: tuple = ()):
|
||||
cursor = self._connection.cursor()
|
||||
cursor.execute(sql, params)
|
||||
return cursor
|
||||
|
||||
def close(self):
|
||||
self._connection.close()
|
||||
|
||||
|
||||
_register_approved_adapter(_FirebirdAdapter, _ADAPTER_REGISTRATION_TOKEN)
|
||||
_ADAPTER_CREATION_TOKEN = object()
|
||||
|
||||
|
||||
def _create_firebird_adapter(connection: Any, read_only_evidence: dict[str, Any], seal: object) -> _FirebirdAdapter:
|
||||
if seal is not _ADAPTER_CREATION_TOKEN:
|
||||
raise TypeError("adapter construction is private")
|
||||
return _FirebirdAdapter(connection, read_only_evidence, seal)
|
||||
|
||||
|
||||
def _normalized_path(path: Path) -> str:
|
||||
return os.path.normcase(os.path.normpath(str(path.resolve(strict=False))))
|
||||
|
||||
|
||||
def validate_authorized_database_path(path: os.PathLike[str] | str) -> Path:
|
||||
"""Return the resolved authorized target or reject it before driver use."""
|
||||
candidate = Path(path).resolve(strict=False)
|
||||
authorized = AUTHORIZED_DATABASE_PATH.resolve(strict=False)
|
||||
if _normalized_path(candidate) != _normalized_path(authorized):
|
||||
raise ValueError("database path is not the authorized target")
|
||||
if not authorized.is_file() or not candidate.is_file():
|
||||
raise ValueError("authorized database path must be a regular file")
|
||||
return authorized
|
||||
|
||||
|
||||
def validate_authorized_database_identity(
|
||||
identity: Any, path: os.PathLike[str] | str = AUTHORIZED_DATABASE_PATH
|
||||
) -> str:
|
||||
"""Reject an engine identity that does not identify the authorized target."""
|
||||
reported = str(identity or "").strip().strip('"')
|
||||
if not reported:
|
||||
raise ValueError("database identity is unavailable")
|
||||
authorized = Path(path).resolve(strict=False)
|
||||
if any(separator in reported for separator in ("/", "\\")) or ":" in reported:
|
||||
if _normalized_path(Path(reported)) != _normalized_path(authorized):
|
||||
raise ValueError("database identity does not match authorized target")
|
||||
elif os.path.normcase(reported) != os.path.normcase(authorized.name):
|
||||
raise ValueError("database identity does not match authorized target")
|
||||
return reported
|
||||
|
||||
|
||||
def connect_authorized_read_only(
|
||||
path: os.PathLike[str] | str,
|
||||
driver: Any,
|
||||
client_library: str | None = None,
|
||||
) -> FirebirdCatalog:
|
||||
"""Connect only to the authorized file with proven driver read-only mode.
|
||||
|
||||
Returns a ``FirebirdCatalog`` -- the closed, structured read-only API
|
||||
(Mission 00D). No object with a public ``execute(sql)`` is ever handed
|
||||
back to the caller; there is no arbitrary-SQL path from here on.
|
||||
"""
|
||||
database = validate_authorized_database_path(path)
|
||||
options = {"database": str(database), "read_only": True}
|
||||
if client_library is not None:
|
||||
options["client_library"] = client_library
|
||||
raw_connection = driver.connect(**options)
|
||||
try:
|
||||
if not isinstance(raw_connection, _FirebirdAdapter) or raw_connection._seal is not _ADAPTER_CREATION_TOKEN:
|
||||
raise ValueError("driver did not return a trusted sealed read-only adapter")
|
||||
evidence = raw_connection.read_only_evidence
|
||||
if (
|
||||
raw_connection.read_only is not True
|
||||
or not isinstance(evidence, MappingProxyType)
|
||||
or evidence.get("accepted") is not True
|
||||
or evidence.get("driver_api") != "fdb.Connection.default_tpb"
|
||||
or evidence.get("configured_tpb") != b"\x03\x08\x06\x0f\x11"
|
||||
or evidence.get("observed_default_tpb") != evidence.get("configured_tpb")
|
||||
or getattr(raw_connection._connection, "default_tpb", None) != evidence.get("configured_tpb")
|
||||
):
|
||||
raise ValueError("driver did not prove read-only enforcement")
|
||||
return FirebirdCatalog(raw_connection)
|
||||
except BaseException:
|
||||
try:
|
||||
close = getattr(raw_connection, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
except BaseException:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def get_database_identity(catalog: FirebirdCatalog) -> Any:
|
||||
"""Read the database identity through the closed catalog API."""
|
||||
if not isinstance(catalog, FirebirdCatalog):
|
||||
raise TypeError("catalog must be FirebirdCatalog")
|
||||
rows = catalog.get_database_info()
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
if isinstance(row, dict):
|
||||
for key, value in row.items():
|
||||
if str(key).upper() == "MON$DATABASE_NAME":
|
||||
return value
|
||||
return None
|
||||
return row[0] if row else None
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Private operation-key boundary for project-owned Firebird catalog reads.
|
||||
|
||||
The driver can receive SQL only after this module has selected a query from
|
||||
the immutable, module-owned operation map. Callers provide an operation key,
|
||||
never SQL text or SQL fragments.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from types import MappingProxyType
|
||||
|
||||
from .collector import _CATALOG_QUERIES
|
||||
from .identifiers import quote_firebird_identifier
|
||||
_SAMPLE_OPERATION = "sample_relation"
|
||||
_MAX_SAMPLE_ROWS = 50
|
||||
_ADAPTER_REGISTRATION_TOKEN = object()
|
||||
_APPROVED_ADAPTER_TYPES = MappingProxyType({})
|
||||
_INTERNAL_QUERIES = MappingProxyType({
|
||||
"expression_probe": (
|
||||
"SELECT CAST(COUNT(*) AS INTEGER), SUM(MON$ODS_MAJOR), MIN(MON$ODS_MAJOR), "
|
||||
"MAX(MON$ODS_MAJOR), COALESCE(MON$ODS_MINOR, 0) "
|
||||
"FROM MON$DATABASE GROUP BY MON$ODS_MINOR"
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
def _register_approved_adapter(adapter_type: type, token: object) -> None:
|
||||
"""Register an exact adapter type using the private module capability."""
|
||||
global _APPROVED_ADAPTER_TYPES
|
||||
if token is not _ADAPTER_REGISTRATION_TOKEN or not isinstance(adapter_type, type):
|
||||
raise TypeError("adapter registration is private")
|
||||
_APPROVED_ADAPTER_TYPES = MappingProxyType(
|
||||
{**_APPROVED_ADAPTER_TYPES, adapter_type: True}
|
||||
)
|
||||
|
||||
|
||||
class _SamplingCapability:
|
||||
"""Immutable proof that identifiers came from one reader snapshot."""
|
||||
|
||||
__slots__ = ("_relations", "_columns")
|
||||
|
||||
def __init__(self, relations, fields):
|
||||
relation_names = {
|
||||
str(row.get("RELATION_NAME", "")).strip().upper()
|
||||
for row in relations
|
||||
}
|
||||
columns = {}
|
||||
for row in fields:
|
||||
relation = str(row.get("RELATION_NAME", "")).strip().upper()
|
||||
field = str(row.get("FIELD_NAME", "")).strip()
|
||||
columns.setdefault(relation, set()).add(field)
|
||||
object.__setattr__(self, "_relations", frozenset(relation_names))
|
||||
object.__setattr__(self, "_columns", {
|
||||
relation: frozenset(names) for relation, names in columns.items()
|
||||
})
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
raise AttributeError("sampling capability is immutable")
|
||||
|
||||
def allows(self, relation: str, columns: tuple[str, ...]) -> bool:
|
||||
relation_key = relation.strip().upper()
|
||||
return (
|
||||
relation_key in self._relations
|
||||
and set(columns).issubset(self._columns.get(relation_key, frozenset()))
|
||||
)
|
||||
|
||||
|
||||
def _execute_catalog(
|
||||
adapter: Any,
|
||||
operation_key: str,
|
||||
params: tuple = (),
|
||||
*,
|
||||
capability: _SamplingCapability | None = None,
|
||||
) -> Any:
|
||||
"""Execute one approved catalog operation through the private adapter."""
|
||||
if type(operation_key) is not str:
|
||||
raise ValueError("operation key must be an exact string")
|
||||
if operation_key == _SAMPLE_OPERATION:
|
||||
if (
|
||||
not isinstance(params, tuple)
|
||||
or len(params) != 3
|
||||
or type(params[0]) is not str
|
||||
or not isinstance(params[1], tuple)
|
||||
or not params[1]
|
||||
or not all(type(column) is str for column in params[1])
|
||||
or any(not column or column != column.strip() for column in params[1])
|
||||
or type(params[2]) is not int
|
||||
or not (1 <= params[2] <= _MAX_SAMPLE_ROWS)
|
||||
or capability is None
|
||||
or not capability.allows(params[0], params[1])
|
||||
):
|
||||
raise ValueError("invalid sample operation parameters")
|
||||
sql = "SELECT FIRST {} {} FROM {}".format(
|
||||
params[2],
|
||||
", ".join(quote_firebird_identifier(column) for column in params[1]),
|
||||
quote_firebird_identifier(params[0]),
|
||||
)
|
||||
elif operation_key in _CATALOG_QUERIES:
|
||||
sql = _CATALOG_QUERIES[operation_key]
|
||||
elif operation_key in _INTERNAL_QUERIES:
|
||||
sql = _INTERNAL_QUERIES[operation_key]
|
||||
else:
|
||||
raise ValueError("unknown catalog operation")
|
||||
if type(adapter) not in _APPROVED_ADAPTER_TYPES:
|
||||
raise TypeError("adapter is not an approved implementation")
|
||||
if callable(adapter) or not callable(getattr(adapter, "execute", None)):
|
||||
raise TypeError("adapter must expose an execute method")
|
||||
if getattr(adapter, "read_only", None) is not True:
|
||||
raise ValueError("adapter must declare database-level read-only enforcement")
|
||||
return adapter.execute(sql, () if operation_key == _SAMPLE_OPERATION else params)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Validation and quoting for identifiers issued by the Firebird catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def quote_firebird_identifier(name: str) -> str:
|
||||
"""Quote one exact catalog identifier, never an SQL expression."""
|
||||
if not isinstance(name, str) or not name or name != name.strip():
|
||||
raise ValueError("identifier must be an exact non-empty string")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in name):
|
||||
raise ValueError("identifier must not contain control characters")
|
||||
if any(token in name for token in (".", ";", "--", "/*", "*/", "(", ")", "'", ",")):
|
||||
raise ValueError("identifier must be a single catalog name")
|
||||
return '"' + name.replace('"', '""') + '"'
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Conservative, fixture-driven schema mapping with no database access."""
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
_SENSITIVE = ("PASSWORD", "SECRET", "TOKEN", "CREDENTIAL", "AUTH", "SOURCE", "DEFAULT", "BODY")
|
||||
_PRODUCT_WORDS = ("PRODUCT", "PRODUTO", "ITEM", "MATERIAL", "SKU", "ARTICLE")
|
||||
_FISCAL_WORDS = ("NCM", "CEST", "CST", "CSOSN", "CFOP", "ICMS", "PIS", "COFINS", "IPI", "FCP", "ST", "ORIGIN", "RATE", "REDUCTION", "NATURE", "OPERATION", "COMPANY", "BRANCH", "REGIME", "UF", "IAT", "IPPT", "INCIDENCE", "INCIDENCIA")
|
||||
_MEDIA_WORDS = ("IMAGE", "IMG", "PHOTO", "FILE", "PATH", "URL", "BLOB", "DOCUMENT", "ATTACH")
|
||||
_FIELD_GROUPS = {
|
||||
"id": ("ID", "KEY", "IDENTIFIER"),
|
||||
"code": ("CODE", "CODIGO", "SKU", "INTERNAL"),
|
||||
"description": ("DESCRIPTION", "DESCRICAO", "NAME", "NOME"),
|
||||
"barcode": ("BARCODE", "EAN", "GTIN"),
|
||||
"reference": ("REFERENCE", "REFERENCIA", "REF"),
|
||||
"unit": ("UNIT", "UNIDADE"),
|
||||
"category": ("GROUP", "CATEGORY", "GRUPO", "CATEGORIA"),
|
||||
"brand": ("BRAND", "MARCA", "MANUFACTURER", "FABRICANTE"),
|
||||
"status": ("STATUS", "ACTIVE", "ATIVO"),
|
||||
"timestamp": ("CREATED", "UPDATED", "MODIFIED", "CHANGED", "DATE", "TIMESTAMP"),
|
||||
}
|
||||
_PRODUCT_SEMANTIC_WORDS = tuple(
|
||||
word
|
||||
for group in ("code", "description", "barcode", "reference", "unit", "category", "brand", "status")
|
||||
for word in _FIELD_GROUPS[group]
|
||||
)
|
||||
_PRODUCT_SEMANTIC_WORDS += ("PRODUTO", "ESTOQUE")
|
||||
|
||||
|
||||
def _clean(value: Any, key: str = "") -> Any:
|
||||
upper = str(key).upper()
|
||||
if any(word in upper for word in _SENSITIVE):
|
||||
return "<redacted>"
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return "<redacted>"
|
||||
if isinstance(value, Mapping):
|
||||
return {str(k): _clean(v, str(k)) for k, v in value.items() if not any(word in str(k).upper() for word in _SENSITIVE)}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_clean(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
if any(word in value.upper() for word in _SENSITIVE):
|
||||
return "<redacted>"
|
||||
return value if len(value) <= 256 else "<redacted>"
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return value
|
||||
return "<redacted>"
|
||||
|
||||
|
||||
def _rows(metadata: Mapping, name: str) -> list[dict]:
|
||||
value = metadata.get(name, [])
|
||||
return [dict(item) for item in value if isinstance(item, Mapping)]
|
||||
|
||||
|
||||
def _value(row: Mapping, *names: str, default=None):
|
||||
lowered = {str(k).lower(): v for k, v in row.items()}
|
||||
for name in names:
|
||||
if name.lower() in lowered:
|
||||
return lowered[name.lower()]
|
||||
return default
|
||||
|
||||
|
||||
def _field_name(row: Mapping) -> str:
|
||||
return str(_value(row, "name", "field", "field_name", "rdb$field_name", default="")).strip()
|
||||
|
||||
|
||||
def _relation_name(row: Mapping) -> str:
|
||||
return str(_value(row, "relation", "relation_name", "table", "table_name", "name", "rdb$relation_name", default="")).strip()
|
||||
|
||||
|
||||
def _sample_rows(samples: Any, relation: str) -> list[dict]:
|
||||
if isinstance(samples, Mapping):
|
||||
value = samples.get(relation, samples.get(relation.upper(), []))
|
||||
else:
|
||||
value = [row for row in samples or [] if isinstance(row, Mapping) and _relation_name(row).upper() == relation.upper()]
|
||||
return [dict(row) for row in value if isinstance(row, Mapping)]
|
||||
|
||||
|
||||
def _matches(name: str, words: tuple[str, ...]) -> bool:
|
||||
upper = name.upper()
|
||||
return any(word in upper for word in words)
|
||||
|
||||
|
||||
def _primary_keys(keys: list[dict], relation: str) -> list[str]:
|
||||
found = []
|
||||
for key in keys:
|
||||
if _relation_name(key).upper() != relation.upper():
|
||||
continue
|
||||
kind = str(_value(key, "constraint_type", "type", default="")).upper()
|
||||
if "PRIMARY" not in kind and not _value(key, "primary", "is_primary", default=False):
|
||||
continue
|
||||
fields = _value(key, "fields", "columns", default=None)
|
||||
if fields is None:
|
||||
fields = [_value(key, "field", "field_name", default="")]
|
||||
found.extend(str(field).strip() for field in fields if str(field).strip())
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def _foreign_key(row: Mapping) -> tuple[str, str, str, str]:
|
||||
return (
|
||||
str(_value(row, "child_relation", "child_relation_name", default="")).strip(),
|
||||
str(_value(row, "child_field", "child_field_name", default="")).strip(),
|
||||
str(_value(row, "referenced_relation", "referenced_relation_name", "parent_relation", default="")).strip(),
|
||||
str(_value(row, "referenced_field", "referenced_field_name", "parent_field", default="")).strip(),
|
||||
)
|
||||
|
||||
|
||||
def _classification(*, named: bool, structural: bool = False, data: bool = False) -> str:
|
||||
if structural and data:
|
||||
return "CONFIRMED"
|
||||
if named or structural:
|
||||
return "INFERRED"
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def identify_product_candidates(metadata, samples) -> list[dict]:
|
||||
"""Return sanitized product relation candidates derived solely from fixtures."""
|
||||
fields = _rows(metadata, "fields")
|
||||
by_relation = defaultdict(list)
|
||||
for field in fields:
|
||||
relation = _relation_name(field)
|
||||
if relation:
|
||||
by_relation[relation.upper()].append(field)
|
||||
candidates = []
|
||||
for relation_row in _rows(metadata, "relations"):
|
||||
relation = _relation_name(relation_row)
|
||||
if not relation:
|
||||
continue
|
||||
relation_fields = by_relation[relation.upper()]
|
||||
pk = _primary_keys(_rows(metadata, "keys"), relation)
|
||||
sample = _sample_rows(samples, relation)
|
||||
data = bool(sample and any(any(value is not None for value in row.values()) for row in sample))
|
||||
named = _matches(relation, _PRODUCT_WORDS)
|
||||
structural = bool(pk and relation_fields)
|
||||
semantic_fields = [
|
||||
_field_name(field)
|
||||
for field in relation_fields
|
||||
if _matches(_field_name(field), _PRODUCT_SEMANTIC_WORDS + _FISCAL_WORDS)
|
||||
]
|
||||
semantic_sample_fields = [
|
||||
name
|
||||
for name in semantic_fields
|
||||
if any(_value(row, name, default=None) is not None for row in sample)
|
||||
]
|
||||
semantic = bool(semantic_fields and semantic_sample_fields)
|
||||
evidence = {
|
||||
"primary_key": pk,
|
||||
"field_count": len(relation_fields),
|
||||
"semantic_fields": sorted(semantic_fields),
|
||||
}
|
||||
if sample:
|
||||
evidence["sample_fields"] = sorted({str(key) for row in sample for key in row if not any(word in str(key).upper() for word in _SENSITIVE)})
|
||||
evidence["semantic_sample_fields"] = sorted(semantic_sample_fields)
|
||||
count = _value(relation_row, "approximate_count", "count", "row_count", default=None)
|
||||
if count is not None:
|
||||
evidence["approximate_count"] = _clean(count)
|
||||
product_semantic = [field for field in semantic_fields if not _matches(field, _FISCAL_WORDS)]
|
||||
candidates.append({
|
||||
"relation": relation,
|
||||
"classification": _classification(named=named or bool(product_semantic), structural=structural and semantic, data=data and semantic),
|
||||
"evidence": evidence,
|
||||
})
|
||||
return candidates
|
||||
|
||||
|
||||
def _field_candidates(fields: list[dict], samples: list[dict], words: tuple[str, ...], *, include_type=False) -> dict:
|
||||
result = {}
|
||||
for field in fields:
|
||||
name = _field_name(field)
|
||||
if not name or not _matches(name, words) or any(word in name.upper() for word in _SENSITIVE):
|
||||
continue
|
||||
has_data = any(_value(row, name, default=None) is not None for row in samples)
|
||||
item = {"classification": _classification(named=True, data=has_data), "sample_present": has_data}
|
||||
if include_type:
|
||||
item["type"] = _clean(_value(field, "type", "type_name", "field_type", default="UNKNOWN"))
|
||||
subtype = _value(field, "subtype", "field_sub_type", "field_subtype", default=None)
|
||||
if subtype is not None:
|
||||
item["subtype"] = _clean(subtype)
|
||||
result[name] = item
|
||||
return result
|
||||
|
||||
|
||||
def _relation_fields(metadata: Mapping) -> dict[str, list[dict]]:
|
||||
grouped = defaultdict(list)
|
||||
for field in _rows(metadata, "fields"):
|
||||
relation = _relation_name(field)
|
||||
if relation:
|
||||
grouped[relation.upper()].append(field)
|
||||
return grouped
|
||||
|
||||
|
||||
def _relation_profile(relation: str, fields: list[dict], samples: list[dict], foreign_keys: list[dict], product_relation: str) -> dict:
|
||||
relationships = []
|
||||
for fk in foreign_keys:
|
||||
child, child_field, parent, parent_field = _foreign_key(fk)
|
||||
if child.upper() == relation.upper() or parent.upper() == relation.upper():
|
||||
relationships.append({"child_relation": child, "child_field": child_field, "referenced_relation": parent, "referenced_field": parent_field})
|
||||
mapped_fields = {}
|
||||
for field in fields:
|
||||
name = _field_name(field)
|
||||
if not name or any(word in name.upper() for word in _SENSITIVE):
|
||||
continue
|
||||
item = {
|
||||
"classification": "INFERRED",
|
||||
"sample_present": any(_value(row, name, default=None) is not None for row in samples),
|
||||
"type": _clean(_value(field, "type", "type_name", "field_type", default="UNKNOWN")),
|
||||
}
|
||||
subtype = _value(field, "subtype", "field_sub_type", "field_subtype", default=None)
|
||||
if subtype is not None:
|
||||
item["subtype"] = _clean(subtype)
|
||||
if "BLOB" in str(item["type"]).upper():
|
||||
item["value"] = "<redacted>"
|
||||
mapped_fields[name] = item
|
||||
return {"classification": "CONFIRMED" if relationships and samples else ("INFERRED" if fields else "UNKNOWN"), "fields": mapped_fields, "relationships": relationships}
|
||||
|
||||
|
||||
def build_schema_mapping(metadata, samples) -> dict:
|
||||
"""Build a redacted mapping contract from catalog-shaped fixture data."""
|
||||
candidates = identify_product_candidates(metadata, samples)
|
||||
def candidate_score(item: dict) -> tuple[int, int, int, int, int]:
|
||||
evidence = item["evidence"]
|
||||
product_fields = [
|
||||
field for field in evidence.get("semantic_fields", [])
|
||||
if not _matches(field, _FISCAL_WORDS)
|
||||
]
|
||||
return (
|
||||
len(product_fields),
|
||||
int(_matches(item["relation"], _PRODUCT_WORDS)),
|
||||
int(bool(evidence.get("primary_key"))),
|
||||
len(evidence.get("semantic_sample_fields", [])),
|
||||
int(item["classification"] == "CONFIRMED"),
|
||||
)
|
||||
|
||||
selected = max(
|
||||
(item for item in candidates if item["classification"] != "UNKNOWN"),
|
||||
key=candidate_score,
|
||||
default=None,
|
||||
)
|
||||
confirmed = selected if selected and selected["classification"] == "CONFIRMED" else None
|
||||
product_relation = selected["relation"] if selected else None
|
||||
fields_by_relation = _relation_fields(metadata)
|
||||
product_fields = fields_by_relation.get(str(product_relation).upper(), []) if product_relation else []
|
||||
product_samples = _sample_rows(samples, product_relation) if product_relation else []
|
||||
fiscal = _field_candidates(product_fields, product_samples, _FISCAL_WORDS, include_type=True)
|
||||
media = _field_candidates(product_fields, product_samples, _MEDIA_WORDS, include_type=True)
|
||||
blob_fields = [field for field in product_fields if "BLOB" in str(_value(field, "type", "type_name", default="")).upper()]
|
||||
for field in blob_fields:
|
||||
name = _field_name(field)
|
||||
media.setdefault(name, {"classification": "INFERRED", "sample_present": False, "type": "BLOB"})["value"] = "<redacted>"
|
||||
|
||||
foreign_keys = _rows(metadata, "foreign_keys")
|
||||
stock_relations = {}
|
||||
media_relations = {}
|
||||
fiscal_relations = {}
|
||||
for relation in _rows(metadata, "relations"):
|
||||
name = _relation_name(relation)
|
||||
relation_fields = fields_by_relation.get(name.upper(), [])
|
||||
relation_samples = _sample_rows(samples, name)
|
||||
profile = _relation_profile(name, relation_fields, relation_samples, foreign_keys, str(product_relation))
|
||||
linked = any(child.upper() == name.upper() and parent.upper() == str(product_relation).upper() for child, _, parent, _ in (_foreign_key(fk) for fk in foreign_keys))
|
||||
if name and (_matches(name, ("STOCK", "INVENTORY", "ESTOQUE")) or linked):
|
||||
stock_relations[name] = {"classification": "CONFIRMED" if linked else "INFERRED", "evidence": "foreign-key relationship" if linked else "relation name", "fields": profile["fields"], "relationships": profile["relationships"]}
|
||||
if name and (_matches(name, _MEDIA_WORDS) or any(_matches(_field_name(field), _MEDIA_WORDS) or "BLOB" in str(_value(field, "type", "type_name", default="")).upper() for field in relation_fields)):
|
||||
media_relations[name] = profile
|
||||
if name and (_matches(name, ("FISCAL", "TAX", "TRIBUT")) or any(_matches(_field_name(field), _FISCAL_WORDS) for field in relation_fields)):
|
||||
fiscal_relations[name] = profile
|
||||
|
||||
triggers = []
|
||||
dependencies = defaultdict(list)
|
||||
for dependency in _rows(metadata, "dependencies"):
|
||||
dependent = str(_value(dependency, "dependent", "dependent_name", default="")).strip()
|
||||
depended_on = str(_value(dependency, "depended_on", "depended_on_name", default="")).strip()
|
||||
if dependent and depended_on:
|
||||
dependencies[dependent.upper()].append(depended_on)
|
||||
for trigger in _rows(metadata, "triggers"):
|
||||
trigger_relation = _relation_name(trigger)
|
||||
trigger_name = str(_value(trigger, "name", "trigger_name", default="")).strip()
|
||||
if trigger_relation.upper() == str(product_relation).upper() or _matches(trigger_name, _PRODUCT_WORDS):
|
||||
triggers.append({"classification": "INFERRED", "relation": trigger_relation, "trigger": trigger_name, "timing": _clean(_value(trigger, "timing", default="UNKNOWN")) if _value(trigger, "timing", default="UNKNOWN") is not None else "UNKNOWN", "event": _clean(_value(trigger, "event", default="UNKNOWN")) if _value(trigger, "event", default="UNKNOWN") is not None else "UNKNOWN", "trigger_type": _clean(_value(trigger, "trigger_type", default="UNKNOWN")) if _value(trigger, "trigger_type", default="UNKNOWN") is not None else "UNKNOWN", "dependencies": sorted(dependencies.get(trigger_name.upper(), [])), "source": "sanitized metadata only"})
|
||||
incremental = {"classification": "INFERRED" if triggers or _rows(metadata, "generators") or any(_matches(_field_name(f), _FIELD_GROUPS["timestamp"]) for f in product_fields) else "UNKNOWN", "evidence": {"timestamps": [ _field_name(f) for f in product_fields if _matches(_field_name(f), _FIELD_GROUPS["timestamp"])], "triggers": len(triggers), "generators": len(_rows(metadata, "generators"))}}
|
||||
return {
|
||||
"product": {"classification": selected["classification"] if selected else "UNKNOWN", "relation": product_relation, "evidence": selected["evidence"] if selected else {}, "candidates": candidates},
|
||||
"fields": _field_candidates(product_fields, product_samples, tuple(word for words in _FIELD_GROUPS.values() for word in words), include_type=True),
|
||||
"fiscal": {"classification": "CONFIRMED" if (fiscal or fiscal_relations) and confirmed else ("INFERRED" if fiscal or fiscal_relations else "UNKNOWN"), "fields": fiscal, "relations": fiscal_relations},
|
||||
"stock": {"classification": "CONFIRMED" if any(item["classification"] == "CONFIRMED" for item in stock_relations.values()) else ("INFERRED" if stock_relations else "UNKNOWN"), "relations": stock_relations},
|
||||
"media": {"classification": "CONFIRMED" if (media or media_relations) and confirmed else ("INFERRED" if media or media_relations else "UNKNOWN"), "fields": media, "relations": media_relations},
|
||||
"incremental": incremental,
|
||||
"triggers": triggers,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Credential-free provenance checks for the Firebird Python driver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
APPROVED_SITE_PACKAGES_ROOT = Path(sysconfig.get_paths()["purelib"]).resolve(strict=False)
|
||||
|
||||
|
||||
class ProvenanceError(RuntimeError):
|
||||
"""Raised when the driver origin cannot be proven unambiguously."""
|
||||
|
||||
def __init__(self, message: str, evidence: dict[str, Any] | None = None):
|
||||
super().__init__(message)
|
||||
self.evidence = evidence
|
||||
|
||||
|
||||
def _resolved(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return str(Path(value).resolve(strict=False))
|
||||
except (OSError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _under(path: str | None, directory: str | None) -> bool:
|
||||
if not path or not directory:
|
||||
return False
|
||||
try:
|
||||
Path(path).relative_to(Path(directory))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _metadata_value(metadata: Any, name: str) -> Any:
|
||||
for key in (name, name.lower(), name.title()):
|
||||
try:
|
||||
value = metadata.get(key)
|
||||
except AttributeError:
|
||||
value = None
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _project_shadowing() -> list[str]:
|
||||
findings: list[str] = []
|
||||
try:
|
||||
for path in PROJECT_ROOT.rglob("*"):
|
||||
if path.name in {"fdb.py", "fdb"} or path.suffix == ".pth":
|
||||
findings.append(str(path.resolve(strict=False)))
|
||||
except OSError:
|
||||
return ["<project-scan-unavailable>"]
|
||||
return sorted(set(findings))
|
||||
|
||||
|
||||
def _module_candidates() -> list[str]:
|
||||
candidates = []
|
||||
for entry in sys.path:
|
||||
root = Path(entry or Path.cwd())
|
||||
for candidate in (root / "fdb.py", root / "fdb" / "__init__.py"):
|
||||
if candidate.is_file():
|
||||
candidates.append(str(candidate.resolve(strict=False)))
|
||||
return sorted(set(candidates))
|
||||
|
||||
|
||||
def inspect_fdb_provenance() -> dict[str, Any]:
|
||||
"""Inspect ``fdb`` origin without importing or executing the module."""
|
||||
module_file = None
|
||||
module_package = "fdb"
|
||||
module_error = None
|
||||
try:
|
||||
spec = importlib.util.find_spec("fdb")
|
||||
module_file = _resolved(getattr(spec, "origin", None)) if spec else None
|
||||
if spec and getattr(spec, "name", None):
|
||||
module_package = str(spec.name)
|
||||
except (ImportError, AttributeError, ValueError, OSError) as error:
|
||||
module_error = type(error).__name__
|
||||
|
||||
distribution_name = "fdb"
|
||||
distribution_version = None
|
||||
distribution_location = None
|
||||
distribution_error = None
|
||||
try:
|
||||
distribution = importlib.metadata.distribution("fdb")
|
||||
distribution_name = str(_metadata_value(distribution.metadata, "Name") or "fdb")
|
||||
distribution_version = str(getattr(distribution, "version", None) or
|
||||
_metadata_value(distribution.metadata, "Version") or "") or None
|
||||
location = getattr(distribution, "locate_file", None)
|
||||
if callable(location):
|
||||
distribution_location = _resolved(location(""))
|
||||
if distribution_location is None:
|
||||
distribution_location = _resolved(getattr(distribution, "location", None))
|
||||
except (importlib.metadata.PackageNotFoundError, ImportError, OSError, AttributeError) as error:
|
||||
distribution_error = type(error).__name__
|
||||
|
||||
shadowing = _project_shadowing()
|
||||
module_candidates = _module_candidates()
|
||||
ambiguous = len(module_candidates) > 1
|
||||
module_under_distribution = _under(module_file, distribution_location)
|
||||
module_under_approved_root = _under(module_file, str(APPROVED_SITE_PACKAGES_ROOT))
|
||||
distribution_under_approved_root = _under(distribution_location, str(APPROVED_SITE_PACKAGES_ROOT))
|
||||
status = "VALID"
|
||||
if (module_error or distribution_error or not module_file or not distribution_version or
|
||||
not distribution_location or not module_under_distribution or
|
||||
not module_under_approved_root or not distribution_under_approved_root or
|
||||
shadowing or ambiguous):
|
||||
status = "UNKNOWN"
|
||||
return {
|
||||
"status": status,
|
||||
"module_file": module_file,
|
||||
"module_package": module_package,
|
||||
"module_version": distribution_version,
|
||||
"distribution_name": distribution_name,
|
||||
"distribution_version": distribution_version,
|
||||
"distribution_location": distribution_location,
|
||||
"module_under_distribution": module_under_distribution,
|
||||
"approved_site_packages_root": str(APPROVED_SITE_PACKAGES_ROOT),
|
||||
"module_under_approved_root": module_under_approved_root,
|
||||
"distribution_under_approved_root": distribution_under_approved_root,
|
||||
"module_candidates": module_candidates,
|
||||
"ambiguous_origin": ambiguous,
|
||||
"interpreter": _resolved(sys.executable),
|
||||
"working_directory": _resolved(Path.cwd()),
|
||||
"sys_path": [_resolved(item) or str(item) for item in sys.path],
|
||||
"pythonpath": [item for item in os.environ.get("PYTHONPATH", "").split(os.pathsep) if item],
|
||||
"shadowing": shadowing,
|
||||
"errors": sorted(item for item in (module_error, distribution_error) if item),
|
||||
}
|
||||
|
||||
|
||||
def validate_fdb_provenance(expected_version: str = "2.0.4") -> dict[str, Any]:
|
||||
"""Return proven provenance or raise a sanitized, deterministic error."""
|
||||
evidence = inspect_fdb_provenance()
|
||||
if evidence["ambiguous_origin"]:
|
||||
raise ProvenanceError("fdb provenance has ambiguous origins", evidence)
|
||||
if evidence["shadowing"]:
|
||||
raise ProvenanceError("fdb provenance is blocked by project shadowing", evidence)
|
||||
if not evidence["module_under_approved_root"] or not evidence["distribution_under_approved_root"]:
|
||||
raise ProvenanceError("fdb provenance is outside the approved environment", evidence)
|
||||
if evidence["status"] != "VALID":
|
||||
raise ProvenanceError("fdb provenance is unknown", evidence)
|
||||
if evidence["distribution_name"].lower() != "fdb":
|
||||
raise ProvenanceError("fdb distribution name mismatch", evidence)
|
||||
if evidence["distribution_version"] != expected_version:
|
||||
raise ProvenanceError("fdb distribution version mismatch", evidence)
|
||||
evidence["validated_version"] = expected_version
|
||||
return evidence
|
||||
|
||||
|
||||
def validate_imported_fdb_provenance(
|
||||
fdb_module: Any, expected_version: str = "2.0.4"
|
||||
) -> dict[str, Any]:
|
||||
"""Re-check the concrete imported module against pre-import evidence."""
|
||||
evidence = inspect_fdb_provenance()
|
||||
imported_file = _resolved(getattr(fdb_module, "__file__", None))
|
||||
imported_package = getattr(fdb_module, "__package__", None)
|
||||
imported_version = getattr(fdb_module, "__version__", None)
|
||||
evidence["imported_module_file"] = imported_file
|
||||
evidence["imported_module_package"] = imported_package
|
||||
evidence["imported_module_version"] = imported_version
|
||||
if (
|
||||
imported_file != evidence["module_file"]
|
||||
or imported_package != "fdb"
|
||||
or not _under(imported_file, str(APPROVED_SITE_PACKAGES_ROOT))
|
||||
):
|
||||
evidence["status"] = "BLOCKED"
|
||||
evidence["blocked_reason"] = "imported fdb module origin or package mismatch"
|
||||
raise ProvenanceError("imported fdb provenance is blocked", evidence)
|
||||
if imported_version != expected_version:
|
||||
evidence["status"] = "BLOCKED"
|
||||
evidence["blocked_reason"] = "imported fdb version mismatch"
|
||||
raise ProvenanceError("imported fdb provenance version is blocked", evidence)
|
||||
evidence["validated_version"] = expected_version
|
||||
evidence["status"] = "VALID"
|
||||
return evidence
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Offline, auxiliary SQL sanity checker -- NOT the security boundary.
|
||||
|
||||
IMPORTANT (Mission 00D architectural decision): this module is kept only
|
||||
as a defense-in-depth / offline diagnostic tool. It is deliberately no
|
||||
longer wired into the live read path -- ``gti_discovery.catalog`` never
|
||||
calls ``validate_read_only`` and exposes no ``execute(sql)``-shaped API
|
||||
at all, so arbitrary caller-provided SQL text can never reach the
|
||||
Firebird driver in the first place. A regex/lexical SQL "blacklist" can
|
||||
never be a complete or reliable security boundary against a real SQL
|
||||
grammar (this file's own history proves it -- see git log / Mission 00D
|
||||
report for the quoted-identifier bypass and the CAST/aggregate false
|
||||
rejections that motivated the redesign). It is retained here, fixed and
|
||||
tested, purely so it can still be used as an optional, non-authoritative
|
||||
sanity check if some future caller-facing tool ever needs one -- it must
|
||||
never again be trusted as the sole gate before execution.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
_SIDE_EFFECT_SQL = re.compile(
|
||||
r"\b(?:INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|TRUNCATE|EXECUTE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FIRST_KEYWORD = re.compile(r"\A(SELECT|WITH)\b", re.IGNORECASE)
|
||||
_SIDE_EFFECTING_SELECT = re.compile(
|
||||
r"(?:\bFROM\s+(?:[A-Z][\w$]*\s*\.\s*)*[A-Z][\w$]*\s*\(|"
|
||||
r"\bGEN_ID\s*\(|\bNEXT\s+VALUE\s+FOR\b|\bWITH\s+LOCK\b|"
|
||||
r"\bFOR\s+UPDATE\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CALLABLE = re.compile(
|
||||
r"\b(?P<qualified>(?:[A-Z][\w$]*\s*\.\s*)*[A-Z][\w$]*)\s*\(",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# A bare quoted identifier immediately followed by "(" -- e.g.
|
||||
# "SOME_PROCEDURE"(1,2) -- used to be invisible to _CALLABLE once quoted
|
||||
# text was masked to spaces by _scan_sql/_normalize_outside_quotes. That
|
||||
# was the real bypass: masking hid the callable name instead of still
|
||||
# flagging "there was a callable here". This pattern reads the ORIGINAL
|
||||
# (unmasked) SQL to catch that shape explicitly.
|
||||
_QUOTED_CALLABLE = re.compile(r'"(?:[^"]|"")*"\s*\(')
|
||||
_DETERMINISTIC_CALLABLES = {
|
||||
"ABS", "AS", "AVG", "CAST", "CEILING", "CHAR_LENGTH", "COALESCE", "COUNT",
|
||||
"EXTRACT", "FLOOR", "IIF", "LOWER", "MAX", "MIN", "NULLIF", "ROUND",
|
||||
"SUBSTRING", "SUM", "TRIM", "UPPER",
|
||||
}
|
||||
def _scan_sql(sql: str) -> tuple[str, int]:
|
||||
"""Return SQL with quoted literals/identifiers masked and semicolon count."""
|
||||
masked: list[str] = []
|
||||
semicolons = 0
|
||||
index = 0
|
||||
while index < len(sql):
|
||||
char = sql[index]
|
||||
if char in ("'", '"'):
|
||||
quote = char
|
||||
masked.append(" ")
|
||||
index += 1
|
||||
while index < len(sql):
|
||||
masked.append(" ")
|
||||
if sql[index] == quote:
|
||||
if index + 1 < len(sql) and sql[index + 1] == quote:
|
||||
masked.append(" ")
|
||||
index += 2
|
||||
continue
|
||||
index += 1
|
||||
break
|
||||
index += 1
|
||||
else:
|
||||
raise ValueError("unterminated quoted SQL value")
|
||||
continue
|
||||
if sql.startswith("--", index):
|
||||
raise ValueError("SQL comments are not allowed")
|
||||
if sql.startswith("/*", index):
|
||||
raise ValueError("SQL comments are not allowed")
|
||||
if char == ";":
|
||||
semicolons += 1
|
||||
masked.append(char)
|
||||
index += 1
|
||||
return "".join(masked), semicolons
|
||||
|
||||
|
||||
def _normalize_outside_quotes(sql: str) -> str:
|
||||
normalized: list[str] = []
|
||||
pending_space = False
|
||||
index = 0
|
||||
while index < len(sql):
|
||||
char = sql[index]
|
||||
if char in ("'", '"'):
|
||||
if pending_space and normalized:
|
||||
normalized.append(" ")
|
||||
pending_space = False
|
||||
quote = char
|
||||
normalized.append(char)
|
||||
index += 1
|
||||
while index < len(sql):
|
||||
normalized.append(sql[index])
|
||||
if sql[index] == quote:
|
||||
if index + 1 < len(sql) and sql[index + 1] == quote:
|
||||
normalized.append(sql[index + 1])
|
||||
index += 2
|
||||
continue
|
||||
index += 1
|
||||
break
|
||||
index += 1
|
||||
continue
|
||||
if char.isspace():
|
||||
if normalized:
|
||||
pending_space = True
|
||||
else:
|
||||
if pending_space and normalized:
|
||||
normalized.append(" ")
|
||||
pending_space = False
|
||||
normalized.append(char)
|
||||
index += 1
|
||||
return "".join(normalized).rstrip()
|
||||
|
||||
|
||||
def validate_read_only(sql: str) -> str:
|
||||
"""Normalize and validate that *sql* is one safe, read-only query."""
|
||||
if not isinstance(sql, str):
|
||||
raise ValueError("SQL must be a string")
|
||||
|
||||
candidate = sql.strip()
|
||||
if candidate.endswith(";"):
|
||||
candidate = candidate[:-1].rstrip()
|
||||
if not candidate:
|
||||
raise ValueError("SQL query cannot be empty")
|
||||
|
||||
masked, semicolons = _scan_sql(candidate)
|
||||
if semicolons:
|
||||
raise ValueError("multiple SQL statements are not allowed")
|
||||
normalized = _normalize_outside_quotes(candidate)
|
||||
normalized_masked = " ".join(masked.split())
|
||||
if not _FIRST_KEYWORD.match(normalized_masked):
|
||||
raise ValueError("only SELECT and WITH queries are allowed")
|
||||
if _SIDE_EFFECT_SQL.search(normalized_masked):
|
||||
raise ValueError("SQL has a prohibited side effect")
|
||||
if _SIDE_EFFECTING_SELECT.search(normalized_masked):
|
||||
raise ValueError("SQL contains a known side-effecting construct")
|
||||
# A quoted identifier used as a callable (e.g. "SOME_PROC"(1)) is
|
||||
# invisible to _CALLABLE once masking hides its text -- check the
|
||||
# unmasked, normalized SQL directly so it can never bypass the
|
||||
# callable allowlist just by being quoted (the original bypass this
|
||||
# module was rewritten to close, see module docstring).
|
||||
if _QUOTED_CALLABLE.search(normalized):
|
||||
raise ValueError("SQL contains an unapproved quoted callable")
|
||||
for match in _CALLABLE.finditer(normalized_masked):
|
||||
name = match.group("qualified").rsplit(".", 1)[-1].strip().upper()
|
||||
if name not in _DETERMINISTIC_CALLABLES:
|
||||
raise ValueError("SQL contains an unapproved callable")
|
||||
return normalized
|
||||
Reference in New Issue
Block a user