Files
adaptive-multi-protocol-gat…/ampg/service_manifest.py
T

881 lines
33 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import json
from pathlib import Path
import re
from typing import Any
from urllib.parse import urlsplit
from .bip340 import bip340_verify, tagged_hash as _tagged_hash
from .canonical_json import MAX_SAFE_INTEGER, canonicalize_jcs, strict_json_loads
from .capabilities import INTERACTION_TIERS
SERVICE_MANIFEST_SCHEMA = "amp.service-manifest.v1"
SERVICE_MANIFEST_SCHEMA_PATH = Path("schemas/amp.service-manifest.v1.schema.json")
DELEGATION_SCHEMA = "amp.service-key-delegation.v1"
SIGNATURE_ALGORITHM = "BIP340-secp256k1"
CANONICALIZATION = "RFC8785-JCS"
TRANSPORTS = ("clearnet", "tor", "i2p", "gemini", "ipfs", "reticulum")
PUBLIC_INTERACTION_TIERS = tuple(tier for tier in INTERACTION_TIERS if tier != "internal")
AUTH_METHODS = ("none", "nip98", "smirk-action", "smirk-session", "siwe", "http-session")
PAYMENT_METHODS = (
"none",
"smirk-wallet",
"server-invoice",
"payment-capability",
"static-instructions",
)
MAX_ROUTES = 64
MAX_MANIFEST_BYTES = 256 * 1024
MAX_CLOCK_SKEW = timedelta(minutes=5)
HEX_32_RE = re.compile(r"^[0-9a-f]{64}$")
HEX_64_RE = re.compile(r"^[0-9a-f]{128}$")
SERVICE_ID_RE = re.compile(r"^amp:[0-9a-f]{64}$")
ONION_V3_RE = re.compile(r"^[a-z2-7]{56}\.onion$")
RFC3339_RE = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
)
TOP_LEVEL_FIELDS = {"schema", "payload", "delegation", "signature"}
PAYLOAD_FIELDS = {
"service_id",
"name",
"root_public_key",
"api",
"routes",
"issued_at",
"expires_at",
"sequence",
"previous_manifest_digest",
}
API_FIELDS = {"contract", "version", "schema_sha256"}
ROUTE_FIELDS = {
"transport",
"endpoint",
"context",
"interaction_tier",
"priority",
"auth",
"payments",
"capabilities",
}
SIGNATURE_FIELDS = {"algorithm", "canonicalization", "public_key", "value"}
DELEGATION_FIELDS = {"payload", "signature"}
DELEGATION_PAYLOAD_FIELDS = {
"schema",
"service_id",
"root_public_key",
"signing_public_key",
"issued_at",
"expires_at",
"sequence",
}
@dataclass(frozen=True)
class ServiceManifestIssue:
path: str
code: str
message: str
def read_service_manifest(path: Path) -> dict[str, Any]:
try:
raw = path.read_bytes()
if len(raw) > MAX_MANIFEST_BYTES:
raise ValueError(f"manifest exceeds {MAX_MANIFEST_BYTES} bytes")
data = strict_json_loads(raw.decode("utf-8"))
except FileNotFoundError as exc:
raise ValueError(f"service manifest not found: {path}") from exc
except (UnicodeError, json.JSONDecodeError, ValueError) as exc:
raise ValueError(f"invalid service manifest JSON: {path}: {exc}") from exc
if not isinstance(data, dict):
raise ValueError(f"invalid service manifest JSON: {path}: root must be an object")
return data
def service_id_for_root_key(root_public_key: str) -> str:
key = _decode_hex(root_public_key, 32, "root public key")
return f"amp:{_tagged_hash('AMPG/service-id/v1', key).hex()}"
def service_manifest_digest(payload: dict[str, Any]) -> bytes:
return _tagged_hash("AMPG/service-manifest/v1", canonicalize_jcs(payload))
def delegation_digest(payload: dict[str, Any]) -> bytes:
return _tagged_hash("AMPG/service-key-delegation/v1", canonicalize_jcs(payload))
def validate_service_manifest(
data: Any,
*,
now: datetime | None = None,
minimum_sequence: int | None = None,
minimum_delegation_sequence: int | None = None,
expected_previous: str | None = None,
verify_signature: bool = True,
) -> list[ServiceManifestIssue]:
issues: list[ServiceManifestIssue] = []
if not isinstance(data, dict):
return [ServiceManifestIssue("$", "type", "service manifest must be a JSON object")]
_unknown_fields("$", data, TOP_LEVEL_FIELDS, issues)
_required_fields("$", data, {"schema", "payload", "signature"}, issues)
if data.get("schema") != SERVICE_MANIFEST_SCHEMA:
issues.append(
ServiceManifestIssue(
"$.schema", "schema", f"must be {SERVICE_MANIFEST_SCHEMA!r}"
)
)
payload = data.get("payload")
payload_times = _validate_payload(payload, issues)
signature = data.get("signature")
signing_key = _validate_signature_object("$.signature", signature, issues)
root_key = payload.get("root_public_key") if isinstance(payload, dict) else None
service_id = payload.get("service_id") if isinstance(payload, dict) else None
delegation = data.get("delegation")
delegation_times: tuple[datetime | None, datetime | None] = (None, None)
if delegation is not None:
delegation_times = _validate_delegation(
delegation,
root_key=root_key,
service_id=service_id,
signing_key=signing_key,
issues=issues,
verify_signature=verify_signature,
)
elif signing_key and root_key and signing_key != root_key:
issues.append(
ServiceManifestIssue(
"$.delegation",
"required",
"a delegated signing key requires a root-signed delegation",
)
)
elif signing_key and root_key and signing_key == root_key:
pass
if delegation is not None and signing_key and root_key and signing_key == root_key:
issues.append(
ServiceManifestIssue(
"$.delegation",
"unexpected",
"delegation is only valid when the signing key differs from the root key",
)
)
issued_at, expires_at = payload_times
delegated_at, delegated_until = delegation_times
if issued_at and expires_at and issued_at >= expires_at:
issues.append(
ServiceManifestIssue(
"$.payload.expires_at", "range", "must be later than issued_at"
)
)
if issued_at and delegated_at and issued_at < delegated_at:
issues.append(
ServiceManifestIssue(
"$.payload.issued_at", "delegation-window", "precedes delegation validity"
)
)
if expires_at and delegated_until and expires_at > delegated_until:
issues.append(
ServiceManifestIssue(
"$.payload.expires_at", "delegation-window", "exceeds delegation validity"
)
)
if now is not None:
check_time = _as_utc(now)
if issued_at and issued_at > check_time + MAX_CLOCK_SKEW:
issues.append(
ServiceManifestIssue(
"$.payload.issued_at", "not-yet-valid", "is too far in the future"
)
)
if expires_at and expires_at <= check_time:
issues.append(ServiceManifestIssue("$.payload.expires_at", "expired", "has expired"))
if delegated_at and delegated_at > check_time + MAX_CLOCK_SKEW:
issues.append(
ServiceManifestIssue(
"$.delegation.payload.issued_at",
"not-yet-valid",
"delegation is too far in the future",
)
)
if delegated_until and delegated_until <= check_time:
issues.append(
ServiceManifestIssue(
"$.delegation.payload.expires_at", "expired", "delegation has expired"
)
)
sequence = payload.get("sequence") if isinstance(payload, dict) else None
previous = payload.get("previous_manifest_digest") if isinstance(payload, dict) else None
if minimum_sequence is not None and isinstance(sequence, int) and sequence < minimum_sequence:
issues.append(
ServiceManifestIssue(
"$.payload.sequence",
"rollback",
f"must be at least the pinned sequence {minimum_sequence}",
)
)
if expected_previous is not None and previous != expected_previous:
issues.append(
ServiceManifestIssue(
"$.payload.previous_manifest_digest",
"chain",
"does not match the pinned previous manifest digest",
)
)
delegation_sequence = None
if isinstance(delegation, dict) and isinstance(delegation.get("payload"), dict):
delegation_sequence = delegation["payload"].get("sequence")
if (
minimum_delegation_sequence is not None
and isinstance(delegation_sequence, int)
and delegation_sequence < minimum_delegation_sequence
):
issues.append(
ServiceManifestIssue(
"$.delegation.payload.sequence",
"rollback",
f"must be at least the pinned delegation sequence {minimum_delegation_sequence}",
)
)
if verify_signature and isinstance(payload, dict) and isinstance(signature, dict):
try:
digest = service_manifest_digest(payload)
except (TypeError, ValueError):
pass
else:
_verify_signed_payload("$.signature", signature, digest, issues)
return issues
def service_manifest_json_schema() -> dict[str, Any]:
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:amp:schema:service-manifest:v1",
"title": "AMP Signed Service Manifest",
"description": "Signed public identity and multi-transport route contract.",
"type": "object",
"additionalProperties": False,
"required": ["schema", "payload", "signature"],
"properties": {
"schema": {"const": SERVICE_MANIFEST_SCHEMA},
"payload": {"$ref": "#/$defs/payload"},
"delegation": {"$ref": "#/$defs/delegation"},
"signature": {"$ref": "#/$defs/signature"},
},
"$defs": {
"hex32": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"hex64": {"type": "string", "pattern": "^[0-9a-f]{128}$"},
"timestamp": {"type": "string", "format": "date-time", "maxLength": 40},
"signature": {
"type": "object",
"additionalProperties": False,
"required": ["algorithm", "canonicalization", "public_key", "value"],
"properties": {
"algorithm": {"const": SIGNATURE_ALGORITHM},
"canonicalization": {"const": CANONICALIZATION},
"public_key": {"$ref": "#/$defs/hex32"},
"value": {"$ref": "#/$defs/hex64"},
},
},
"api": {
"type": "object",
"additionalProperties": False,
"required": ["contract", "version", "schema_sha256"],
"properties": {
"contract": {"type": "string", "minLength": 1, "maxLength": 120},
"version": {"type": "string", "minLength": 1, "maxLength": 40},
"schema_sha256": {"$ref": "#/$defs/hex32"},
},
},
"route": {
"type": "object",
"additionalProperties": False,
"required": [
"transport",
"endpoint",
"context",
"interaction_tier",
"priority",
"auth",
"payments",
],
"properties": {
"transport": {"enum": list(TRANSPORTS)},
"endpoint": {"type": "string", "minLength": 1, "maxLength": 2048},
"context": {"enum": list(TRANSPORTS)},
"interaction_tier": {"enum": list(PUBLIC_INTERACTION_TIERS)},
"priority": {"type": "integer", "minimum": 0, "maximum": 1000},
"auth": {
"type": "array",
"minItems": 1,
"uniqueItems": True,
"items": {"enum": list(AUTH_METHODS)},
},
"payments": {
"type": "array",
"minItems": 1,
"uniqueItems": True,
"items": {"enum": list(PAYMENT_METHODS)},
},
"capabilities": {
"type": "array",
"maxItems": 64,
"uniqueItems": True,
"items": {"type": "string", "minLength": 1, "maxLength": 120},
},
},
},
"payload": {
"type": "object",
"additionalProperties": False,
"required": [
"service_id",
"name",
"root_public_key",
"routes",
"issued_at",
"expires_at",
"sequence",
],
"properties": {
"service_id": {"type": "string", "pattern": "^amp:[0-9a-f]{64}$"},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"root_public_key": {"$ref": "#/$defs/hex32"},
"api": {"$ref": "#/$defs/api"},
"routes": {
"type": "array",
"minItems": 1,
"maxItems": MAX_ROUTES,
"items": {"$ref": "#/$defs/route"},
},
"issued_at": {"$ref": "#/$defs/timestamp"},
"expires_at": {"$ref": "#/$defs/timestamp"},
"sequence": {
"type": "integer",
"minimum": 1,
"maximum": MAX_SAFE_INTEGER,
},
"previous_manifest_digest": {"$ref": "#/$defs/hex32"},
},
},
"delegationPayload": {
"type": "object",
"additionalProperties": False,
"required": [
"schema",
"service_id",
"root_public_key",
"signing_public_key",
"issued_at",
"expires_at",
"sequence",
],
"properties": {
"schema": {"const": DELEGATION_SCHEMA},
"service_id": {"type": "string", "pattern": "^amp:[0-9a-f]{64}$"},
"root_public_key": {"$ref": "#/$defs/hex32"},
"signing_public_key": {"$ref": "#/$defs/hex32"},
"issued_at": {"$ref": "#/$defs/timestamp"},
"expires_at": {"$ref": "#/$defs/timestamp"},
"sequence": {
"type": "integer",
"minimum": 1,
"maximum": MAX_SAFE_INTEGER,
},
},
},
"delegation": {
"type": "object",
"additionalProperties": False,
"required": ["payload", "signature"],
"properties": {
"payload": {"$ref": "#/$defs/delegationPayload"},
"signature": {"$ref": "#/$defs/signature"},
},
},
},
}
def service_manifest_schema_json() -> str:
return json.dumps(service_manifest_json_schema(), indent=2, sort_keys=True) + "\n"
def _validate_payload(
payload: Any, issues: list[ServiceManifestIssue]
) -> tuple[datetime | None, datetime | None]:
path = "$.payload"
if not isinstance(payload, dict):
issues.append(ServiceManifestIssue(path, "type", "must be an object"))
return None, None
_unknown_fields(path, payload, PAYLOAD_FIELDS, issues)
_required_fields(
path,
payload,
{
"service_id",
"name",
"root_public_key",
"routes",
"issued_at",
"expires_at",
"sequence",
},
issues,
)
_string(path + ".name", payload.get("name"), issues, minimum=1, maximum=120)
service_id = payload.get("service_id")
root_key = payload.get("root_public_key")
_pattern(path + ".service_id", service_id, SERVICE_ID_RE, issues)
_pattern(path + ".root_public_key", root_key, HEX_32_RE, issues)
if isinstance(service_id, str) and isinstance(root_key, str) and HEX_32_RE.fullmatch(root_key):
expected = service_id_for_root_key(root_key)
if service_id != expected:
issues.append(
ServiceManifestIssue(
path + ".service_id", "service-id", "does not match root_public_key"
)
)
if "api" in payload:
_validate_api(payload["api"], issues)
_validate_routes(payload.get("routes"), issues)
sequence = payload.get("sequence")
_integer(path + ".sequence", sequence, issues, minimum=1, maximum=MAX_SAFE_INTEGER)
if "previous_manifest_digest" in payload:
_pattern(
path + ".previous_manifest_digest",
payload["previous_manifest_digest"],
HEX_32_RE,
issues,
)
issued_at = _timestamp(path + ".issued_at", payload.get("issued_at"), issues)
expires_at = _timestamp(path + ".expires_at", payload.get("expires_at"), issues)
try:
canonicalize_jcs(payload)
except (TypeError, ValueError) as exc:
issues.append(ServiceManifestIssue(path, "canonicalization", str(exc)))
return issued_at, expires_at
def _validate_api(value: Any, issues: list[ServiceManifestIssue]) -> None:
path = "$.payload.api"
if not isinstance(value, dict):
issues.append(ServiceManifestIssue(path, "type", "must be an object"))
return
_unknown_fields(path, value, API_FIELDS, issues)
_required_fields(path, value, API_FIELDS, issues)
_string(path + ".contract", value.get("contract"), issues, minimum=1, maximum=120)
_string(path + ".version", value.get("version"), issues, minimum=1, maximum=40)
_pattern(path + ".schema_sha256", value.get("schema_sha256"), HEX_32_RE, issues)
def _validate_routes(value: Any, issues: list[ServiceManifestIssue]) -> None:
path = "$.payload.routes"
if not isinstance(value, list):
issues.append(ServiceManifestIssue(path, "type", "must be an array"))
return
if not value:
issues.append(ServiceManifestIssue(path, "range", "must contain at least one route"))
if len(value) > MAX_ROUTES:
issues.append(
ServiceManifestIssue(path, "range", f"must contain at most {MAX_ROUTES} routes")
)
seen: set[tuple[str, str]] = set()
for index, route in enumerate(value):
route_path = f"{path}[{index}]"
if not isinstance(route, dict):
issues.append(ServiceManifestIssue(route_path, "type", "must be an object"))
continue
_unknown_fields(route_path, route, ROUTE_FIELDS, issues)
_required_fields(
route_path,
route,
{
"transport",
"endpoint",
"context",
"interaction_tier",
"priority",
"auth",
"payments",
},
issues,
)
transport = route.get("transport")
context = route.get("context")
endpoint = route.get("endpoint")
_enum(route_path + ".transport", transport, TRANSPORTS, issues)
_enum(route_path + ".context", context, TRANSPORTS, issues)
if isinstance(transport, str) and isinstance(context, str) and context != transport:
issues.append(
ServiceManifestIssue(
route_path + ".context", "context", "must match transport in v1"
)
)
_enum(
route_path + ".interaction_tier",
route.get("interaction_tier"),
PUBLIC_INTERACTION_TIERS,
issues,
)
_integer(route_path + ".priority", route.get("priority"), issues, minimum=0, maximum=1000)
_enum_array(route_path + ".auth", route.get("auth"), AUTH_METHODS, issues)
_enum_array(route_path + ".payments", route.get("payments"), PAYMENT_METHODS, issues)
if "capabilities" in route:
_string_array(route_path + ".capabilities", route["capabilities"], issues)
if isinstance(endpoint, str) and isinstance(transport, str) and transport in TRANSPORTS:
_validate_endpoint(route_path + ".endpoint", transport, endpoint, issues)
key = (transport, endpoint)
if key in seen:
issues.append(
ServiceManifestIssue(route_path, "duplicate", "duplicates another route")
)
seen.add(key)
else:
_string(route_path + ".endpoint", endpoint, issues, minimum=1, maximum=2048)
def _validate_endpoint(
path: str, transport: str, endpoint: str, issues: list[ServiceManifestIssue]
) -> None:
_string(path, endpoint, issues, minimum=1, maximum=2048)
if not endpoint or len(endpoint) > 2048:
return
try:
parsed = urlsplit(endpoint)
port = parsed.port
except ValueError as exc:
issues.append(ServiceManifestIssue(path, "format", f"invalid endpoint: {exc}"))
return
if parsed.username or parsed.password or parsed.fragment or parsed.query:
issues.append(
ServiceManifestIssue(path, "format", "must not include credentials, query, or fragment")
)
host = (parsed.hostname or "").lower()
allowed_schemes = {
"clearnet": {"https"},
"tor": {"http", "https"},
"i2p": {"http", "https"},
"gemini": {"gemini"},
"ipfs": {"ipfs"},
"reticulum": {"reticulum"},
}[transport]
if parsed.scheme.lower() not in allowed_schemes:
issues.append(
ServiceManifestIssue(
path,
"transport-endpoint",
f"{transport} endpoint must use one of: {', '.join(sorted(allowed_schemes))}",
)
)
if transport == "tor" and not ONION_V3_RE.fullmatch(host):
issues.append(ServiceManifestIssue(path, "transport-endpoint", "must use a v3 .onion host"))
elif transport == "i2p" and not host.endswith(".i2p"):
issues.append(ServiceManifestIssue(path, "transport-endpoint", "must use an .i2p host"))
elif transport in {"clearnet", "gemini"} and not host:
issues.append(ServiceManifestIssue(path, "transport-endpoint", "must include a host"))
elif transport == "ipfs" and not (parsed.netloc or parsed.path):
issues.append(
ServiceManifestIssue(path, "transport-endpoint", "must include an IPFS address")
)
elif transport == "reticulum" and not (parsed.netloc or parsed.path):
issues.append(
ServiceManifestIssue(path, "transport-endpoint", "must include a Reticulum destination")
)
if port is not None and not (1 <= port <= 65535):
issues.append(ServiceManifestIssue(path, "range", "port must be between 1 and 65535"))
def _validate_signature_object(
path: str, value: Any, issues: list[ServiceManifestIssue]
) -> str | None:
if not isinstance(value, dict):
issues.append(ServiceManifestIssue(path, "type", "must be an object"))
return None
_unknown_fields(path, value, SIGNATURE_FIELDS, issues)
_required_fields(path, value, SIGNATURE_FIELDS, issues)
if value.get("algorithm") != SIGNATURE_ALGORITHM:
issues.append(
ServiceManifestIssue(path + ".algorithm", "algorithm", f"must be {SIGNATURE_ALGORITHM}")
)
if value.get("canonicalization") != CANONICALIZATION:
issues.append(
ServiceManifestIssue(
path + ".canonicalization",
"canonicalization",
f"must be {CANONICALIZATION}",
)
)
public_key = value.get("public_key")
_pattern(path + ".public_key", public_key, HEX_32_RE, issues)
_pattern(path + ".value", value.get("value"), HEX_64_RE, issues)
return public_key if isinstance(public_key, str) and HEX_32_RE.fullmatch(public_key) else None
def _validate_delegation(
value: Any,
*,
root_key: Any,
service_id: Any,
signing_key: str | None,
issues: list[ServiceManifestIssue],
verify_signature: bool,
) -> tuple[datetime | None, datetime | None]:
path = "$.delegation"
if not isinstance(value, dict):
issues.append(ServiceManifestIssue(path, "type", "must be an object"))
return None, None
_unknown_fields(path, value, DELEGATION_FIELDS, issues)
_required_fields(path, value, DELEGATION_FIELDS, issues)
payload = value.get("payload")
signature = value.get("signature")
delegation_key = _validate_signature_object(path + ".signature", signature, issues)
if not isinstance(payload, dict):
issues.append(ServiceManifestIssue(path + ".payload", "type", "must be an object"))
return None, None
_unknown_fields(path + ".payload", payload, DELEGATION_PAYLOAD_FIELDS, issues)
_required_fields(path + ".payload", payload, DELEGATION_PAYLOAD_FIELDS, issues)
if payload.get("schema") != DELEGATION_SCHEMA:
issues.append(
ServiceManifestIssue(path + ".payload.schema", "schema", f"must be {DELEGATION_SCHEMA}")
)
_pattern(path + ".payload.service_id", payload.get("service_id"), SERVICE_ID_RE, issues)
_pattern(path + ".payload.root_public_key", payload.get("root_public_key"), HEX_32_RE, issues)
_pattern(
path + ".payload.signing_public_key",
payload.get("signing_public_key"),
HEX_32_RE,
issues,
)
_integer(
path + ".payload.sequence",
payload.get("sequence"),
issues,
minimum=1,
maximum=MAX_SAFE_INTEGER,
)
if service_id is not None and payload.get("service_id") != service_id:
issues.append(
ServiceManifestIssue(path + ".payload.service_id", "binding", "service mismatch")
)
if root_key is not None and payload.get("root_public_key") != root_key:
issues.append(
ServiceManifestIssue(path + ".payload.root_public_key", "binding", "root key mismatch")
)
if signing_key is not None and payload.get("signing_public_key") != signing_key:
issues.append(
ServiceManifestIssue(
path + ".payload.signing_public_key", "binding", "signing key mismatch"
)
)
if root_key is not None and delegation_key is not None and delegation_key != root_key:
issues.append(
ServiceManifestIssue(path + ".signature.public_key", "binding", "must be the root key")
)
issued_at = _timestamp(path + ".payload.issued_at", payload.get("issued_at"), issues)
expires_at = _timestamp(path + ".payload.expires_at", payload.get("expires_at"), issues)
if issued_at and expires_at and issued_at >= expires_at:
issues.append(
ServiceManifestIssue(
path + ".payload.expires_at", "range", "must be later than issued_at"
)
)
try:
canonicalize_jcs(payload)
except (TypeError, ValueError) as exc:
issues.append(ServiceManifestIssue(path + ".payload", "canonicalization", str(exc)))
if verify_signature and isinstance(signature, dict):
try:
digest = delegation_digest(payload)
except (TypeError, ValueError):
pass
else:
_verify_signed_payload(path + ".signature", signature, digest, issues)
return issued_at, expires_at
def _verify_signed_payload(
path: str,
signature: dict[str, Any],
digest: bytes,
issues: list[ServiceManifestIssue],
) -> None:
try:
public_key = _decode_hex(signature.get("public_key"), 32, "public key")
value = _decode_hex(signature.get("value"), 64, "signature")
except (TypeError, ValueError):
return
if not bip340_verify(public_key, digest, value):
issues.append(ServiceManifestIssue(path + ".value", "signature", "verification failed"))
def _timestamp(
path: str, value: Any, issues: list[ServiceManifestIssue]
) -> datetime | None:
if (
not isinstance(value, str)
or not value
or len(value) > 40
or not RFC3339_RE.fullmatch(value)
):
issues.append(ServiceManifestIssue(path, "type", "must be an RFC 3339 timestamp string"))
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
issues.append(ServiceManifestIssue(path, "format", "must be an RFC 3339 timestamp"))
return None
if parsed.tzinfo is None:
issues.append(ServiceManifestIssue(path, "format", "must include a UTC offset"))
return None
return _as_utc(parsed)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
raise ValueError("datetime must be timezone-aware")
return value.astimezone(timezone.utc)
def _unknown_fields(
path: str,
value: dict[str, Any],
allowed: set[str],
issues: list[ServiceManifestIssue],
) -> None:
for field in sorted(set(value) - allowed):
issues.append(ServiceManifestIssue(f"{path}.{field}", "unknown-field", "unknown field"))
def _required_fields(
path: str,
value: dict[str, Any],
required: set[str],
issues: list[ServiceManifestIssue],
) -> None:
for field in sorted(required - set(value)):
issues.append(ServiceManifestIssue(f"{path}.{field}", "required", "missing required field"))
def _string(
path: str,
value: Any,
issues: list[ServiceManifestIssue],
*,
minimum: int,
maximum: int,
) -> None:
if not isinstance(value, str):
issues.append(ServiceManifestIssue(path, "type", "must be a string"))
elif not minimum <= len(value) <= maximum:
issues.append(
ServiceManifestIssue(path, "range", f"length must be between {minimum} and {maximum}")
)
def _pattern(
path: str,
value: Any,
pattern: re.Pattern[str],
issues: list[ServiceManifestIssue],
) -> None:
if not isinstance(value, str):
issues.append(ServiceManifestIssue(path, "type", "must be a string"))
elif not pattern.fullmatch(value):
issues.append(ServiceManifestIssue(path, "format", "has invalid format"))
def _enum(
path: str,
value: Any,
allowed: tuple[str, ...],
issues: list[ServiceManifestIssue],
) -> None:
if value not in allowed:
issues.append(
ServiceManifestIssue(path, "enum", f"must be one of: {', '.join(allowed)}")
)
def _integer(
path: str,
value: Any,
issues: list[ServiceManifestIssue],
*,
minimum: int,
maximum: int,
) -> None:
if isinstance(value, bool) or not isinstance(value, int):
issues.append(ServiceManifestIssue(path, "type", "must be an integer"))
elif not minimum <= value <= maximum:
issues.append(
ServiceManifestIssue(path, "range", f"must be between {minimum} and {maximum}")
)
def _enum_array(
path: str,
value: Any,
allowed: tuple[str, ...],
issues: list[ServiceManifestIssue],
) -> None:
if not isinstance(value, list):
issues.append(ServiceManifestIssue(path, "type", "must be an array"))
return
if not value:
issues.append(ServiceManifestIssue(path, "range", "must not be empty"))
seen: set[str] = set()
for index, item in enumerate(value):
_enum(f"{path}[{index}]", item, allowed, issues)
if isinstance(item, str) and item in seen:
issues.append(ServiceManifestIssue(f"{path}[{index}]", "duplicate", "duplicate value"))
if isinstance(item, str):
seen.add(item)
if "none" in seen and len(seen) > 1:
issues.append(ServiceManifestIssue(path, "combination", "none cannot be combined"))
def _string_array(path: str, value: Any, issues: list[ServiceManifestIssue]) -> None:
if not isinstance(value, list):
issues.append(ServiceManifestIssue(path, "type", "must be an array"))
return
if len(value) > 64:
issues.append(ServiceManifestIssue(path, "range", "must contain at most 64 values"))
seen: set[str] = set()
for index, item in enumerate(value):
item_path = f"{path}[{index}]"
_string(item_path, item, issues, minimum=1, maximum=120)
if isinstance(item, str) and item in seen:
issues.append(ServiceManifestIssue(item_path, "duplicate", "duplicate value"))
if isinstance(item, str):
seen.add(item)
def _decode_hex(value: Any, size: int, label: str) -> bytes:
if not isinstance(value, str) or len(value) != size * 2:
raise ValueError(f"{label} must be {size} bytes of lowercase hex")
try:
decoded = bytes.fromhex(value)
except ValueError as exc:
raise ValueError(f"{label} must be lowercase hex") from exc
if value != value.lower():
raise ValueError(f"{label} must be lowercase hex")
return decoded