#!/usr/bin/env python3 """Small JSON-schema subset used for worker output contracts. The project intentionally avoids a runtime dependency on jsonschema. Profiles may use the supported, auditable subset documented in PROFILE_SCHEMA.md. """ from __future__ import annotations import json import math import re from collections.abc import Mapping from typing import Any from mmo_util import ( strict_json_decoder, strict_json_loads, valid_absolute_uri, validate_json_unicode, ) SUPPORTED_TYPES = {"object", "array", "string", "integer", "number", "boolean", "null"} SUPPORTED_FORMATS = {"uri", "date", "date-time"} _RFC3339_DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", re.ASCII) _RFC3339_DATE_TIME = re.compile( r"([0-9]{4})-([0-9]{2})-([0-9]{2})" r"[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})" r"(?:\.([0-9]+))?" r"(?:([Zz])|([+-])([0-9]{2}):([0-9]{2}))", re.ASCII, ) def _is_finite_number(value: Any) -> bool: if isinstance(value, bool) or not isinstance(value, (int, float)): return False # Converting an arbitrarily large JSON integer to float can overflow even # though the integer itself is finite. return isinstance(value, int) or math.isfinite(value) def _is_integer_number(value: Any) -> bool: """Return whether a JSON number has a zero fractional part.""" return _is_finite_number(value) and ( isinstance(value, int) or (isinstance(value, float) and value.is_integer()) ) def _json_value_key(value: Any) -> tuple[Any, ...] | None: """Return a strict JSON value key using JSON Schema equality rules.""" if value is None: return ("null",) if isinstance(value, bool): return ("boolean", value) if isinstance(value, int): return ("number", value) if isinstance(value, float): return ("number", value) if math.isfinite(value) else None if isinstance(value, str): return ("string", value) if isinstance(value, list): items = [_json_value_key(item) for item in value] if any(item is None for item in items): return None return ("array", tuple(items)) if isinstance(value, Mapping): if not all(isinstance(key, str) for key in value): return None members = [(key, _json_value_key(value[key])) for key in sorted(value)] if any(item is None for _key, item in members): return None return ("object", tuple(members)) return None def validate_schema_definition(schema: Any, path: str = "$") -> list[str]: errors: list[str] = [] if isinstance(schema, bool): return errors if not isinstance(schema, Mapping): return [f"{path}: schema must be an object or boolean"] if not all(isinstance(key, str) for key in schema): errors.append(f"{path}: schema keyword names must be strings") schema_type = schema.get("type") if schema_type is not None: if isinstance(schema_type, str): if schema_type not in SUPPORTED_TYPES: errors.append(f"{path}.type: unsupported type {schema_type!r}") elif isinstance(schema_type, list): if not schema_type: errors.append(f"{path}.type: array cannot be empty") invalid = [ item for item in schema_type if not isinstance(item, str) or item not in SUPPORTED_TYPES ] if invalid: errors.append(f"{path}.type: unsupported types {invalid!r}") elif len(schema_type) != len(set(schema_type)): errors.append(f"{path}.type: array contains duplicate types") else: errors.append(f"{path}.type: must be a string or array") for keyword in ("oneOf", "anyOf", "allOf"): if keyword in schema: value = schema[keyword] if not isinstance(value, list) or not value: errors.append(f"{path}.{keyword}: must be a non-empty array") else: for index, item in enumerate(value): errors.extend(validate_schema_definition(item, f"{path}.{keyword}[{index}]")) for keyword in ("not", "if", "then", "else"): if keyword in schema: errors.extend(validate_schema_definition(schema[keyword], f"{path}.{keyword}")) if ("then" in schema or "else" in schema) and "if" not in schema: errors.append(f"{path}: then/else requires if") if "properties" in schema: properties = schema["properties"] if not isinstance(properties, Mapping): errors.append(f"{path}.properties: must be an object") else: for key, value in properties.items(): if not isinstance(key, str): errors.append(f"{path}.properties: property names must be strings") continue errors.extend(validate_schema_definition(value, f"{path}.properties.{key}")) if "items" in schema: errors.extend(validate_schema_definition(schema["items"], f"{path}.items")) if "required" in schema: required = schema["required"] if not (isinstance(required, list) and all(isinstance(item, str) for item in required)): errors.append(f"{path}.required: must be an array of strings") elif len(required) != len(set(required)): errors.append(f"{path}.required: contains duplicate property names") if "enum" in schema: enum = schema["enum"] if not isinstance(enum, list) or not enum: errors.append(f"{path}.enum: must be a non-empty array") else: keys = [_json_value_key(item) for item in enum] if any(key is None for key in keys): errors.append(f"{path}.enum: values must be valid finite JSON values") elif len(keys) != len(set(keys)): errors.append(f"{path}.enum: values must be unique") if "const" in schema and _json_value_key(schema["const"]) is None: errors.append(f"{path}.const: must be a valid finite JSON value") for keyword in ("$schema", "$id", "title", "description"): if keyword in schema and not isinstance(schema[keyword], str): errors.append(f"{path}.{keyword}: must be a string") for keyword in ("minLength", "maxLength", "minItems", "maxItems"): if keyword in schema: value = schema[keyword] if not _is_integer_number(value) or value < 0: errors.append(f"{path}.{keyword}: must be a non-negative integer") for minimum, maximum in (("minLength", "maxLength"), ("minItems", "maxItems")): if ( _is_integer_number(schema.get(minimum)) and _is_integer_number(schema.get(maximum)) and schema[minimum] > schema[maximum] ): errors.append(f"{path}: {minimum} exceeds {maximum}") for keyword in ("minimum", "maximum"): if keyword in schema: value = schema[keyword] if not _is_finite_number(value): errors.append(f"{path}.{keyword}: must be a finite number") if ( isinstance(schema.get("minimum"), (int, float)) and not isinstance(schema.get("minimum"), bool) and isinstance(schema.get("maximum"), (int, float)) and not isinstance(schema.get("maximum"), bool) and schema["minimum"] > schema["maximum"] ): errors.append(f"{path}: minimum exceeds maximum") if "uniqueItems" in schema and not isinstance(schema["uniqueItems"], bool): errors.append(f"{path}.uniqueItems: must be boolean") if "additionalProperties" in schema: additional = schema["additionalProperties"] if isinstance(additional, Mapping): errors.extend(validate_schema_definition(additional, f"{path}.additionalProperties")) elif not isinstance(additional, bool): errors.append(f"{path}.additionalProperties: must be boolean or a schema") if "pattern" in schema: pattern = schema["pattern"] if not isinstance(pattern, str): errors.append(f"{path}.pattern: must be a string") else: try: re.compile(pattern) except re.error as exc: errors.append(f"{path}.pattern: invalid regular expression: {exc}") if "format" in schema: schema_format = schema["format"] if not isinstance(schema_format, str) or schema_format not in SUPPORTED_FORMATS: errors.append(f"{path}.format: must be one of {sorted(SUPPORTED_FORMATS)}") known = { "$schema", "$id", "title", "description", "type", "properties", "required", "additionalProperties", "items", "enum", "const", "minLength", "maxLength", "minimum", "maximum", "minItems", "maxItems", "uniqueItems", "pattern", "oneOf", "anyOf", "allOf", "not", "if", "then", "else", "format", } unknown = sorted(key for key in schema if isinstance(key, str) and key not in known) if unknown: errors.append(f"{path}: unsupported schema keywords: {', '.join(unknown)}") return errors def _types(schema: Mapping[str, Any]) -> set[str] | None: value = schema.get("type") if value is None: return None return {value} if isinstance(value, str) else set(value) def _matches_type(value: Any, expected: str) -> bool: if expected == "null": return value is None if expected == "boolean": return isinstance(value, bool) if expected == "integer": return _is_integer_number(value) if expected == "number": return _is_finite_number(value) if expected == "string": return isinstance(value, str) if expected == "array": return isinstance(value, list) if expected == "object": return isinstance(value, Mapping) return False def _rfc3339_month_days(year: int, month: int) -> int: if not 1 <= month <= 12: return 0 leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) month_days = (31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) return month_days[month - 1] def _valid_rfc3339_date(year: int, month: int, day: int) -> bool: return 1 <= day <= _rfc3339_month_days(year, month) def _shift_rfc3339_date(year: int, month: int, day: int, day_delta: int) -> tuple[int, int, int]: """Shift a valid RFC 3339 date by the at-most-one-day offset boundary.""" if day_delta == -1: if day > 1: return year, month, day - 1 if month > 1: month -= 1 else: year -= 1 month = 12 return year, month, _rfc3339_month_days(year, month) if day_delta == 1: if day < _rfc3339_month_days(year, month): return year, month, day + 1 if month < 12: return year, month + 1, 1 return year + 1, 1, 1 return year, month, day def _matches_rfc3339_date(value: str) -> bool: matched = _RFC3339_DATE.fullmatch(value) return matched is not None and _valid_rfc3339_date( int(matched.group(1)), int(matched.group(2)), int(matched.group(3)) ) def _matches_rfc3339_date_time(value: str) -> bool: matched = _RFC3339_DATE_TIME.fullmatch(value) if matched is None: return False year, month, day, hour, minute, second = map(int, matched.groups()[:6]) if not _valid_rfc3339_date(year, month, day) or hour > 23 or minute > 59 or second > 60: return False if matched.group(8) is not None: offset_minutes = 0 else: offset_hour = int(matched.group(10)) offset_minute = int(matched.group(11)) if offset_hour > 23 or offset_minute > 59: return False offset_minutes = offset_hour * 60 + offset_minute if matched.group(9) == "-": offset_minutes = -offset_minutes if second == 60: day_delta, utc_minute = divmod(hour * 60 + minute - offset_minutes, 24 * 60) utc_year, utc_month, utc_day = _shift_rfc3339_date(year, month, day, day_delta) # RFC 3339 section 5.7 allows :60 only at the end of a month in UTC. # The local spelling may fall on the adjacent date after applying its # numeric offset, so validate the shifted UTC calendar date. return utc_minute == 23 * 60 + 59 and utc_day == _rfc3339_month_days(utc_year, utc_month) return True def _matches_format(value: str, schema_format: str) -> bool: if schema_format == "uri": return valid_absolute_uri(value) if schema_format == "date": return _matches_rfc3339_date(value) if schema_format == "date-time": return _matches_rfc3339_date_time(value) return False def validate_instance( value: Any, schema: Mapping[str, Any] | bool, path: str = "$", ) -> list[str]: if schema is True: return [] if schema is False: return [f"{path}: value is rejected by false schema"] errors: list[str] = [] allowed_types = _types(schema) if allowed_types and not any(_matches_type(value, item) for item in allowed_types): return [ f"{path}: expected {' or '.join(sorted(allowed_types))}, got {type(value).__name__}" ] if "const" in schema: value_key = _json_value_key(value) const_key = _json_value_key(schema["const"]) if value_key is None or const_key is None or value_key != const_key: errors.append(f"{path}: expected constant {schema['const']!r}") if "enum" in schema: value_key = _json_value_key(value) enum_keys = {_json_value_key(item) for item in schema["enum"]} if value_key is None or value_key not in enum_keys: errors.append(f"{path}: value {value!r} is not in the allowed enum") if "oneOf" in schema: matches = [not validate_instance(value, item, path) for item in schema["oneOf"]] if sum(matches) != 1: errors.append(f"{path}: value must match exactly one oneOf schema") if "anyOf" in schema: if not any(not validate_instance(value, item, path) for item in schema["anyOf"]): errors.append(f"{path}: value does not match any anyOf schema") if "allOf" in schema: for item in schema["allOf"]: errors.extend(validate_instance(value, item, path)) if "not" in schema and not validate_instance(value, schema["not"], path): errors.append(f"{path}: value matches prohibited not schema") if "if" in schema: branch = "then" if not validate_instance(value, schema["if"], path) else "else" if branch in schema: errors.extend(validate_instance(value, schema[branch], path)) if isinstance(value, str): if len(value) < int(schema.get("minLength", 0)): errors.append(f"{path}: string is shorter than minLength") if "maxLength" in schema and len(value) > int(schema["maxLength"]): errors.append(f"{path}: string is longer than maxLength") if "pattern" in schema and not re.search(str(schema["pattern"]), value): errors.append(f"{path}: string does not match required pattern") if "format" in schema and not _matches_format(value, str(schema["format"])): errors.append(f"{path}: string does not match {schema['format']} format") if isinstance(value, (int, float)) and not isinstance(value, bool): if "minimum" in schema and value < schema["minimum"]: errors.append(f"{path}: value is below minimum") if "maximum" in schema and value > schema["maximum"]: errors.append(f"{path}: value is above maximum") if isinstance(value, list): if len(value) < int(schema.get("minItems", 0)): errors.append(f"{path}: array has fewer than minItems") if "maxItems" in schema and len(value) > int(schema["maxItems"]): errors.append(f"{path}: array has more than maxItems") if schema.get("uniqueItems"): serialized = [_json_value_key(item) for item in value] if any(item is None for item in serialized): errors.append(f"{path}: array items must be valid finite JSON values") elif len(serialized) != len(set(serialized)): errors.append(f"{path}: array items must be unique") item_schema = schema.get("items") if isinstance(item_schema, (Mapping, bool)): for index, item in enumerate(value): errors.extend(validate_instance(item, item_schema, f"{path}[{index}]")) if isinstance(value, Mapping): required = schema.get("required", []) for key in required: if key not in value: errors.append(f"{path}: required property {key!r} is missing") properties = schema.get("properties", {}) additional = schema.get("additionalProperties", True) for key, item in value.items(): if key in properties: errors.extend(validate_instance(item, properties[key], f"{path}.{key}")) elif additional is False: errors.append(f"{path}: additional property {key!r} is not allowed") elif isinstance(additional, Mapping): errors.extend(validate_instance(item, additional, f"{path}.{key}")) return errors def extract_json_document(text: str) -> tuple[Any | None, str | None]: stripped = text.strip() candidates = [stripped] fenced = re.findall(r"```(?:json)?\s*(.*?)```", stripped, flags=re.IGNORECASE | re.DOTALL) candidates.extend(item.strip() for item in fenced) for candidate in candidates: if not candidate: continue try: return strict_json_loads(candidate), None except (json.JSONDecodeError, ValueError): pass decoder = strict_json_decoder() for match in re.finditer(r"[\[{]", stripped): try: value, end = decoder.raw_decode(stripped[match.start() :]) except (json.JSONDecodeError, ValueError): continue try: validate_json_unicode(value) except ValueError: continue remaining = stripped[match.start() + end :].strip() if not remaining or remaining.startswith("```"): return value, None return None, "no valid JSON document was found in the final worker response"