Files
pbf/tools/provider-pipeline/mapping_execution_evidence_audit.py

165 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""只读检查 Mapping 的逐 Style Layer 证据;绝不产生 EXECUTABLE 授权。"""
from __future__ import annotations
import argparse
import hashlib
from collections import Counter, defaultdict
import json
from pathlib import Path
from typing import Any
RESOURCE_PROPERTIES = {
"icon": (("layout", "icon-image"),),
"fill": (("paint", "fill-pattern"),),
"line": (("paint", "line-pattern"),),
}
KNOWN_STATUSES = {"VISUAL_OBJECT_MATCH", "LAYER_ONLY"}
def _direct_predicate(layer: dict[str, Any], component: dict[str, Any]) -> list[Any] | None:
key = component.get("resourceKey")
if not isinstance(key, str) or not key:
return None
for section, property_name in RESOURCE_PROPERTIES.get(component.get("role"), ()):
expr = layer.get(section, {}).get(property_name)
if isinstance(expr, list) and len(expr) == 2 and expr[0] == "get" and isinstance(expr[1], str) and expr[1]:
return ["==", ["get", expr[1]], key]
return None
def audit_mapping(style: dict[str, Any], mapping: dict[str, Any]) -> dict[str, Any]:
"""保守静态诊断:DIRECT 仅是构造候选,不是执行批准或对象身份确认。"""
layers = style.get("layers")
assignments = mapping.get("assignments")
if not isinstance(layers, list) or not isinstance(assignments, list):
raise ValueError("Style/Mapping 结构无效")
records: list[dict[str, Any]] = []
counts: Counter[str] = Counter()
layers_to_classes: dict[str, set[str]] = defaultdict(set)
seen: set[str] = set()
for item in sorted(assignments, key=lambda row: str(row.get("visualObjectId", ""))):
object_id = item.get("visualObjectId")
if not isinstance(object_id, str) or not object_id or object_id in seen:
raise ValueError("Visual Object ID 为空或重复")
seen.add(object_id)
status = item.get("status")
standard = item.get("standardLayer")
evidence = item.get("renderEvidence")
reasons: set[str] = set()
components_out: list[dict[str, Any]] = []
direct_fields: dict[str, set[str]] = defaultdict(set)
sources: set[str] = set()
has_unkeyed = False
if status not in KNOWN_STATUSES or not isinstance(standard, str) or not standard:
reasons.add("UNRESOLVED_CLASSIFICATION")
if not isinstance(evidence, dict) or not isinstance(evidence.get("components"), list) or not evidence["components"]:
reasons.add("MISSING_RENDER_EVIDENCE")
else:
for component in evidence["components"]:
index = component.get("styleIndex")
if type(index) is not int or index < 0 or index >= len(layers):
raise ValueError(f"{object_id}: Style 索引无效")
layer = layers[index]
layer_id = component.get("styleLayerId")
if not isinstance(layer, dict) or layer.get("id") != layer_id:
raise ValueError(f"{object_id}: Style Layer ID 不一致")
source = layer.get("source")
source_layer = layer.get("source-layer")
if not isinstance(source, str) or not source or source_layer != evidence.get("sourceLayer"):
reasons.add("SOURCE_OR_SOURCE_LAYER_MISMATCH")
else:
sources.add(source)
if isinstance(layer_id, str):
layers_to_classes[layer_id].add(standard if status in KNOWN_STATUSES and standard else "RESIDUAL")
pred = _direct_predicate(layer, component)
if pred is None:
has_unkeyed = True
reasons.add("COMPONENT_SELECTOR_NOT_PROVED")
else:
direct_fields[pred[1][1]].add(pred[2])
components_out.append({
"styleLayerId": layer_id, "styleIndex": index,
"drawRole": component.get("role"),
"selectorEvidence": "DIRECT_GET_RESOURCE_KEY_CANDIDATE" if pred is not None else "UNPROVED",
"candidateSelector": pred,
})
if len(sources) > 1:
reasons.add("CROSS_SOURCE_COMPONENTS")
if any(len(keys) > 1 for keys in direct_fields.values()):
reasons.add("CONTRADICTORY_RESOURCE_FIELDS")
if len(components_out) > 1:
reasons.add("MULTI_COMPONENT_IDENTITY_UNVERIFIED")
elif components_out:
reasons.add("PRIMARY_IDENTITY_UNDECLARED")
if status in KNOWN_STATUSES and standard:
# Mapping v2 has draw roles, not reviewed PRIMARY/COMPONENT roles.
reasons.add("STANDARD_TO_DISPLAY_GROUP_CONTRACT_UNBOUND")
if has_unkeyed:
reasons.add("INHERITANCE_REQUIRES_SAME_FEATURE_PROOF")
if components_out:
reasons.add("ORIGINAL_FILTER_REACHABILITY_UNVERIFIED")
if not reasons:
reasons.add("EXECUTION_AUTHORITY_UNDEFINED")
for reason in reasons:
counts[reason] += 1
records.append({
"visualObjectId": object_id, "assignmentStatus": status,
"standardLayer": standard, "renderComponents": components_out,
"blockingReasons": sorted(reasons),
"executionAuthorized": False,
})
mixed_layers = sorted(layer_id for layer_id, categories in layers_to_classes.items() if len(categories) > 1)
return {
"schemaVersion": "navsea-mapping-execution-evidence-audit/v1",
"scope": "STATIC_DIAGNOSTIC_ONLY",
"mappingObjectCount": len(records),
"executionAuthorizedCount": 0,
"blockingReasonCounts": dict(sorted(counts.items())),
"mixedStyleLayerCount": len(mixed_layers),
"mixedStyleLayerIds": mixed_layers,
"records": records,
}
def verify_mapping_style_fingerprint(mapping: dict[str, Any], style_bytes: bytes) -> None:
"""Fail closed if the declared original Style digest differs from this input."""
source = mapping.get("renderEvidenceSource")
if source is None:
return # Old mapping versions have no evidence source, and get no authority.
expected = source.get("styleSha256") if isinstance(source, dict) else None
if not isinstance(expected, str) or len(expected) != 64:
raise ValueError("声明的 Style SHA-256 缺失或格式无效")
actual = hashlib.sha256(style_bytes).hexdigest()
if actual.lower() != expected.lower():
raise ValueError("Mapping 声明的 Style SHA-256 与实际快照不匹配")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--style", type=Path, required=True)
parser.add_argument("--mapping", type=Path, required=True)
parser.add_argument("--output", type=Path, help="可选:写入审计 JSON,禁止用于运行时授权")
args = parser.parse_args()
style_bytes = args.style.read_bytes()
mapping = json.loads(args.mapping.read_text(encoding="utf-8"))
verify_mapping_style_fingerprint(mapping, style_bytes)
result = audit_mapping(json.loads(style_bytes.decode("utf-8")), mapping)
data = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
if args.output is None:
print(data)
else:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(data, encoding="utf-8")
print(json.dumps({
"scope": result["scope"], "mappingObjectCount": result["mappingObjectCount"],
"executionAuthorizedCount": result["executionAuthorizedCount"],
"mixedStyleLayerCount": result["mixedStyleLayerCount"],
"output": str(args.output),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())