#!/usr/bin/env python3 """统一 Provider Policy / Package v1 编译、校验与导出。""" from __future__ import annotations import hashlib import json import re import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Any POLICY_SCHEMA = "navsea-provider-policy/v1" PACKAGE_SCHEMA = "navsea-provider-package/v1" ASSIGNMENT_STATUSES = {"VISUAL_OBJECT_MATCH", "LAYER_ONLY", "COMPONENT_ONLY", "AMBIGUOUS", "UNKNOWN"} DELIVERY_FILES = ( "provider-package.json", "provider-policy.json", "mapping-layer-assignment.json", "resources-style.json", "resources-sprite.json", "resources-sprite.png", "validation.json", "validation.md", "MANIFEST.json", ) def canonical(value: Any) -> bytes: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") def stable_projection(value: Any) -> Any: if isinstance(value, dict): return {k: stable_projection(v) for k, v in value.items() if k not in {"snapshot", "createdAt", "generatedAt"}} if isinstance(value, list): return [stable_projection(v) for v in value] return value def fingerprint(value: Any) -> str: return hashlib.sha256(canonical(stable_projection(value))).hexdigest() def file_fingerprint(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def validate_mapping(mapping: dict[str, Any], style: dict[str, Any] | None = None) -> list[str]: errors: list[str] = [] version = mapping.get("schemaVersion") if version not in {"navsea-standard-layer-mapping/v1", "navsea-standard-layer-mapping/v2"}: return ["mapping:schemaVersion:unsupported"] if version.endswith("/v1"): return errors source = mapping.get("renderEvidenceSource") if not isinstance(source, dict) or source.get("schema") != "actual-visual-object-catalog/v1": errors.append("mapping:renderEvidenceSource:invalid") layers = style.get("layers", []) if style is not None else None by_id = {layer.get("id"): (index, layer) for index, layer in enumerate(layers or [])} seen_evidence: dict[str, bytes] = {} for i, row in enumerate(mapping.get("assignments", [])): evidence = row.get("renderEvidence") if evidence is None: continue visual_id = row.get("visualObjectId") encoded = canonical(evidence) if visual_id in seen_evidence and seen_evidence[visual_id] != encoded: errors.append(f"mapping[{i}]:renderEvidence:nondeterministic_duplicate") seen_evidence[visual_id] = encoded if not isinstance(evidence, dict) or not isinstance(evidence.get("sourceLayer"), str) or not isinstance(evidence.get("geometryRole"), str) or not isinstance(evidence.get("occurrenceCount"), int) or evidence["occurrenceCount"] < 0 or not isinstance(evidence.get("components"), list): errors.append(f"mapping[{i}]:renderEvidence:invalid") continue if re.search(r"", json.dumps(evidence, ensure_ascii=False), re.IGNORECASE): errors.append(f"mapping[{i}]:renderEvidence:script_forbidden") previous = -1 for j, component in enumerate(evidence["components"]): if not isinstance(component, dict): errors.append(f"mapping[{i}]:component[{j}]:invalid") continue index = component.get("styleIndex") layer_id = component.get("styleLayerId") if not isinstance(layer_id, str) or not isinstance(index, int) or isinstance(index, bool) or not isinstance(component.get("role"), str) or not component["role"] or (component.get("resourceKey") is not None and not isinstance(component["resourceKey"], str)) or (component.get("resourceVisualHash") is not None and not isinstance(component["resourceVisualHash"], str)): errors.append(f"mapping[{i}]:component[{j}]:invalid") continue if index < previous: errors.append(f"mapping[{i}]:component_order:invalid") previous = index if layers is not None: entry = by_id.get(layer_id) if entry is None: errors.append(f"mapping[{i}]:component[{j}]:styleLayerId:missing") elif entry[0] != index: errors.append(f"mapping[{i}]:component[{j}]:styleIndex:mismatch") elif entry[1].get("source-layer") != evidence["sourceLayer"]: errors.append(f"mapping[{i}]:component[{j}]:sourceLayer:mismatch") return errors def validate_policy(policy: dict[str, Any], style: dict[str, Any] | None = None) -> dict[str, Any]: errors: list[str] = [] required = ("schemaVersion", "provider", "dataset", "package", "standardCatalog", "resources", "mapping", "display", "pickup", "planning", "danger", "capabilities", "validationRequirements", "unresolved", "limitations", "provenance", "fingerprints") errors += [f"missing:{key}" for key in required if key not in policy] if policy.get("schemaVersion") != POLICY_SCHEMA: errors.append("schemaVersion:unsupported") for key in ("provider", "dataset", "package", "standardCatalog", "resources", "mapping", "display", "pickup", "planning", "danger", "capabilities", "validationRequirements", "provenance", "fingerprints"): if key in policy and not isinstance(policy[key], dict): errors.append(f"type:{key}:object_required") for section, field in (("provider", "providerId"), ("dataset", "datasetId"), ("package", "packageId"), ("package", "version")): value = policy.get(section) if not isinstance(value, dict) or not isinstance(value.get(field), str) or not value[field].strip(): errors.append(f"identity:{section}.{field}:required") mapping = policy.get("mapping", {}).get("assignments", []) if isinstance(policy.get("mapping"), dict) else [] if isinstance(policy.get("mapping"), dict): errors += validate_mapping(policy["mapping"], style) if not isinstance(mapping, list): errors.append("type:mapping.assignments:array_required") else: for i, row in enumerate(mapping): if not isinstance(row, dict) or row.get("status") not in ASSIGNMENT_STATUSES: errors.append(f"mapping[{i}]:invalid_status") continue if row["status"] in {"VISUAL_OBJECT_MATCH", "LAYER_ONLY"} and not row.get("standardLayer"): errors.append(f"mapping[{i}]:standardLayer_required") if row["status"] == "VISUAL_OBJECT_MATCH" and not row.get("standardVisualObjectRef"): errors.append(f"mapping[{i}]:standardVisualObjectRef_required") if row["status"] in {"AMBIGUOUS", "UNKNOWN", "COMPONENT_ONLY"} and row.get("standardLayer") is not None: errors.append(f"mapping[{i}]:unresolved_status_cannot_set_standardLayer") if row["status"] == "AMBIGUOUS" and len(row.get("candidateStandardLayers", [])) < 2: errors.append(f"mapping[{i}]:ambiguous_requires_candidates") return {"schemaVersion": "navsea-provider-validation-report/v1", "result": "FAIL" if errors else "PASS", "errors": errors, "warnings": []} def validate_package(package: dict[str, Any], style: dict[str, Any] | None = None) -> dict[str, Any]: errors: list[str] = [] warnings: list[str] = [] required = ("schemaVersion", "provider", "dataset", "package", "resources", "standardLayerMapping", "providerPolicy", "display", "pickup", "planning", "danger", "capabilities", "validationRequirements", "fingerprints", "unresolved", "limitations", "provenance") errors += [f"missing:{key}" for key in required if key not in package] if package.get("schemaVersion") != PACKAGE_SCHEMA: errors.append("schemaVersion:unsupported") for section, field in (("provider", "providerId"), ("dataset", "datasetId"), ("package", "packageId")): value = package.get(section) if not isinstance(value, dict) or not isinstance(value.get(field), str) or not value[field].strip(): errors.append(f"identity:{section}.{field}:required") package_identity = package.get("package", {}) if not package_identity.get("version"): errors.append("identity:package.version:required") policy_result = validate_policy(package.get("providerPolicy", {}), style) if policy_result["result"] == "FAIL": errors.append("providerPolicy:invalid") requirements = package.get("validationRequirements", {}) if requirements.get("requireStyle") and not package.get("resources", {}).get("style"): errors.append("resource:style:required") if requirements.get("requirePbfSample") and not package.get("resources", {}).get("pbfSamples"): errors.append("resource:pbfSamples:required") if requirements.get("requireSprite") and not package.get("resources", {}).get("sprite", {}).get("uri"): errors.append("resource:sprite:required") if requirements.get("requireGlyphs") and not package.get("resources", {}).get("glyphs", {}).get("uriTemplate"): errors.append("resource:glyphs:required") for resource in package.get("resources", {}).get("pbfSamples", []): if not resource.get("sha256"): warnings.append("resource:pbfSample:hash_missing") mapping = package.get("standardLayerMapping", {}).get("assignments", []) if package.get("standardLayerMapping") != package.get("providerPolicy", {}).get("mapping"): errors.append("mapping:package_policy:mismatch") if package.get("standardLayerMapping", {}).get("schemaVersion") == "navsea-standard-layer-mapping/v2": source = package["standardLayerMapping"].get("renderEvidenceSource", {}) if source.get("styleSha256") != package.get("resources", {}).get("style", {}).get("sha256"): errors.append("mapping:styleSha256:mismatch") if any(row.get("renderEvidence") is None for row in mapping): warnings.append("mapping:renderEvidence_missing") if not mapping: warnings.append("mapping:empty") if package.get("unresolved"): warnings.append("mapping:unresolved_present") if package.get("limitations"): warnings.append("limitations:present") return {"schemaVersion": "navsea-provider-validation-report/v1", "result": "FAIL" if errors else ("PASS_WITH_WARNINGS" if warnings else "PASS"), "errors": errors, "warnings": sorted(set(warnings))} def build_policy(data: dict[str, Any]) -> dict[str, Any]: policy = { "schemaVersion": POLICY_SCHEMA, "provider": data["provider"], "dataset": data["dataset"], "package": data["package"], "standardCatalog": data["standardCatalog"], "resources": data["resources"], "mapping": {"schemaVersion": data.get("mappingSchemaVersion", "navsea-standard-layer-mapping/v1"), "assignments": data["mapping"]}, "display": data.get("display", {}), "pickup": data.get("pickup", {}), "planning": data.get("planning", {}), "danger": data.get("danger", {}), "capabilities": data.get("capabilities", {}), "validationRequirements": data.get("validationRequirements", {}), "unresolved": data.get("unresolved", []), "limitations": data.get("limitations", []), "provenance": data.get("provenance", {}), "fingerprints": data["fingerprints"], } if data.get("renderEvidenceSource"): policy["mapping"]["renderEvidenceSource"] = data["renderEvidenceSource"] policy["mapping"]["diagnostics"] = data.get("mappingDiagnostics", []) policy["fingerprints"]["policy"] = fingerprint({k: v for k, v in policy.items() if k != "fingerprints"}) return policy def build_package(data: dict[str, Any], policy: dict[str, Any], validation: dict[str, Any]) -> dict[str, Any]: return { "schemaVersion": PACKAGE_SCHEMA, "provider": data["provider"], "dataset": data["dataset"], "package": data["package"], "resources": data["resources"], "standardLayerMapping": policy["mapping"], "providerPolicy": policy, "display": policy["display"], "pickup": policy["pickup"], "planning": policy["planning"], "danger": policy["danger"], "capabilities": policy["capabilities"], "validationRequirements": policy["validationRequirements"], "fingerprints": {**data["fingerprints"], "policy": policy["fingerprints"]["policy"]}, "unresolved": policy["unresolved"], "limitations": policy["limitations"], "provenance": policy["provenance"], "validation": validation, } def export_package(output: Path, package: dict[str, Any], policy: dict[str, Any], validation: dict[str, Any]) -> Path: if validation.get("result") not in {"PASS", "PASS_WITH_WARNINGS"}: raise ValueError("只有 PASS 或 PASS_WITH_WARNINGS 可以导出 Provider Package") output.mkdir(parents=True, exist_ok=True) (output / "provider-package.json").write_text(json.dumps(package, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (output / "provider-policy.json").write_text(json.dumps(policy, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (output / "validation.json").write_text(json.dumps(validation, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") md = ["# Provider Package 验证报告", "", f"- 结果:**{validation['result']}**", f"- Provider:`{package['provider']['providerId']}`", f"- 数据集:`{package['dataset']['datasetId']}`", "", "## 错误", ""] md += [f"- {x}" for x in validation.get("errors", [])] or ["- 无"] md += ["", "## 警告", ""] md += [f"- {x}" for x in validation.get("warnings", [])] or ["- 无"] (output / "validation.md").write_text("\n".join(md) + "\n", encoding="utf-8") manifest = { "manifestSchemaVersion": "navsea-provider-package-manifest/v1", "provider": package["provider"], "dataset": package["dataset"], "package": package["package"], "createdAt": None if policy["mapping"]["schemaVersion"].endswith("/v2") else datetime.now(timezone.utc).isoformat(), "fingerprints": package["fingerprints"], "validationResult": validation["result"], "knownLimitations": package["limitations"], "files": {name: file_fingerprint(output / name) for name in ("provider-package.json", "provider-policy.json", "validation.json", "validation.md")}, } (output / "MANIFEST.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return output def _safe_delivery_component(value: str) -> str: cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-._") return cleaned or "provider" def create_delivery_zip(export_dir: Path, provider_id: str, dataset_id: str, version: str) -> dict[str, Any]: """把 PBF-07 的正式 NavSea 输入打成一个严格白名单 ZIP。""" export_dir = Path(export_dir) missing = [name for name in DELIVERY_FILES if not (export_dir / name).is_file()] if missing: raise ValueError(f"PBF-08 缺少正式交付文件,禁止生成 ZIP: {missing}") validation = json.loads((export_dir / "validation.json").read_text(encoding="utf-8")) if validation.get("result") not in {"PASS", "PASS_WITH_WARNINGS"}: raise ValueError(f"PBF-08 只接受 PASS/PASS_WITH_WARNINGS,当前为 {validation.get('result')}") stem = "-".join(_safe_delivery_component(x) for x in (provider_id, dataset_id, version)) zip_path = export_dir.parent / f"{stem}.navsea-provider.zip" root_name = stem if zip_path.exists(): zip_path.unlink() with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: for name in DELIVERY_FILES: info = zipfile.ZipInfo(f"{root_name}/{name}", date_time=(2026, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 archive.writestr(info, (export_dir / name).read_bytes(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) with zipfile.ZipFile(zip_path, "r") as archive: actual = archive.namelist() expected = [f"{root_name}/{name}" for name in DELIVERY_FILES] if actual != expected: zip_path.unlink(missing_ok=True) raise ValueError(f"PBF-08 ZIP 内容校验失败: expected={expected}, actual={actual}") bad = archive.testzip() if bad is not None: zip_path.unlink(missing_ok=True) raise ValueError(f"PBF-08 ZIP CRC 校验失败: {bad}") return { "path": str(zip_path), "filename": zip_path.name, "sha256": file_fingerprint(zip_path), "bytes": zip_path.stat().st_size, "root": root_name, "files": list(DELIVERY_FILES), "validationResult": validation["result"], }