382 lines
14 KiB
Python
382 lines
14 KiB
Python
"""Internal validation helpers for strict, JSON-safe SDK value objects."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import re
|
|
from enum import Enum
|
|
from types import MappingProxyType
|
|
from typing import Any, Mapping
|
|
from urllib.parse import unquote
|
|
from uuid import UUID
|
|
|
|
|
|
WORKLOAD_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
|
|
IDENTIFIER_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$")
|
|
ENTRY_POINT_PATTERN = re.compile(
|
|
r"^[A-Za-z_][A-Za-z0-9_.]*:[A-Za-z_][A-Za-z0-9_.]*(?:@v[1-9][0-9]*)?$"
|
|
)
|
|
SEMVER_PATTERN = re.compile(
|
|
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
|
|
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
|
|
r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
|
|
)
|
|
_VERSION_PATTERN = re.compile(r"^(0|[1-9][0-9]*)(?:\.(0|[1-9][0-9]*))?(?:\.(0|[1-9][0-9]*))?$")
|
|
_VERSION_CLAUSE_PATTERN = re.compile(r"^(==|>=|<=|>|<)\s*(.+)$")
|
|
_FORBIDDEN_LOCATOR_PREFIXES = (
|
|
"file://",
|
|
"worker://",
|
|
"http://",
|
|
"https://",
|
|
"s3://",
|
|
"/",
|
|
)
|
|
_URI_SCHEME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
_WINDOWS_PATH_PATTERN = re.compile(r"^[A-Za-z]:(?:[\\/]|[^\s]*[\\/])")
|
|
_SECRET_ASSIGNMENT_PATTERN = re.compile(
|
|
r"(?i)(?:^|[^A-Za-z0-9_])"
|
|
r"(?:authorization|bearer|token|secret|password|api[-_]?key)\s*[:=]"
|
|
)
|
|
_PATH_ASSIGNMENT_PATTERN = re.compile(
|
|
r"(?i)(?:^|[^A-Za-z0-9_])"
|
|
r"(?:path|file|directory|dir|workspace|cwd|upload|download)\s*[:=]"
|
|
)
|
|
_PATH_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
_FILE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,16}$")
|
|
_TASK_KEY_COMPONENT_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_.-]*$")
|
|
|
|
|
|
def require_exact_keys(
|
|
value: Mapping[str, object],
|
|
expected: set[str],
|
|
label: str,
|
|
*,
|
|
optional: set[str] | None = None,
|
|
) -> None:
|
|
"""Reject unknown fields and report missing required fields."""
|
|
if any(not isinstance(key, str) for key in value):
|
|
raise ValueError(f"{label} must use string field names")
|
|
optional = optional or set()
|
|
actual = set(value)
|
|
missing = expected - actual
|
|
unknown = actual - expected - optional
|
|
if not missing and not unknown:
|
|
return
|
|
details: list[str] = []
|
|
if missing:
|
|
details.append("missing " + ", ".join(sorted(missing)))
|
|
if unknown:
|
|
details.append("unknown " + ", ".join(sorted(unknown)))
|
|
raise ValueError(f"{label} has invalid fields: {'; '.join(details)}")
|
|
|
|
|
|
def require_mapping(value: object, field: str) -> Mapping[str, object]:
|
|
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
|
|
raise ValueError(f"{field} must be an object with string keys")
|
|
return value
|
|
|
|
|
|
def require_string(value: object, field: str, *, max_length: int = 256) -> str:
|
|
if not isinstance(value, str) or not value.strip() or len(value) > max_length:
|
|
raise ValueError(f"{field} must be a non-empty string of at most {max_length} characters")
|
|
if any(ord(character) < 32 for character in value):
|
|
raise ValueError(f"{field} must not contain control characters")
|
|
return value
|
|
|
|
|
|
def require_identifier(value: object, field: str) -> str:
|
|
text = require_string(value, field, max_length=128)
|
|
if not IDENTIFIER_PATTERN.fullmatch(text):
|
|
raise ValueError(f"{field} must be a canonical identifier")
|
|
return text
|
|
|
|
|
|
def require_workload_name(value: object, field: str = "workload.name") -> str:
|
|
text = require_string(value, field, max_length=128)
|
|
if not WORKLOAD_NAME_PATTERN.fullmatch(text):
|
|
raise ValueError(f"{field} must be a canonical hyphenated workload name")
|
|
return text
|
|
|
|
|
|
def require_entry_point(value: object, field: str) -> str:
|
|
text = require_string(value, field, max_length=256)
|
|
if not ENTRY_POINT_PATTERN.fullmatch(text):
|
|
raise ValueError(f"{field} must be a package-owned module:object entry point")
|
|
return text
|
|
|
|
|
|
def require_semver(value: object, field: str) -> str:
|
|
text = require_string(value, field, max_length=64)
|
|
match = SEMVER_PATTERN.fullmatch(text)
|
|
if match is None:
|
|
raise ValueError(f"{field} must be a semantic version such as 1.0.0")
|
|
prerelease = match.group(4)
|
|
if prerelease is not None and any(
|
|
identifier.isdigit() and len(identifier) > 1 and identifier.startswith("0")
|
|
for identifier in prerelease.split(".")
|
|
):
|
|
raise ValueError(f"{field} has a non-canonical numeric prerelease identifier")
|
|
return text
|
|
|
|
|
|
def require_uuid(value: object, field: str) -> str:
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"{field} must be a UUID string")
|
|
try:
|
|
return str(UUID(value))
|
|
except ValueError as error:
|
|
raise ValueError(f"{field} must be a UUID string") from error
|
|
|
|
|
|
def require_sha256(value: object, field: str, *, prefixed: bool = False) -> str:
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"{field} must be a SHA-256 digest")
|
|
digest = value[7:] if prefixed and value.startswith("sha256:") else value
|
|
if prefixed and not value.startswith("sha256:"):
|
|
raise ValueError(f"{field} must use the sha256:<hex> form")
|
|
if not re.fullmatch(r"[0-9a-f]{64}", digest):
|
|
raise ValueError(f"{field} must be a lowercase SHA-256 digest")
|
|
return f"sha256:{digest}" if prefixed else digest
|
|
|
|
|
|
def require_nonnegative_int(value: object, field: str) -> int:
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
raise ValueError(f"{field} must be a non-negative integer")
|
|
return value
|
|
|
|
|
|
def require_positive_int(value: object, field: str) -> int:
|
|
result = require_nonnegative_int(value, field)
|
|
if result == 0:
|
|
raise ValueError(f"{field} must be a positive integer")
|
|
return result
|
|
|
|
|
|
def require_schema_version(value: object, expected: int, field: str) -> int:
|
|
if isinstance(value, bool) or not isinstance(value, int) or value != expected:
|
|
raise ValueError(f"{field} must be the integer {expected}")
|
|
return value
|
|
|
|
|
|
def require_task_key(value: object, field: str = "task_key") -> str:
|
|
text = require_string(value, field, max_length=256)
|
|
parts = text.split("/")
|
|
if any(
|
|
not part or part in {".", ".."} or not _TASK_KEY_COMPONENT_PATTERN.fullmatch(part)
|
|
for part in parts
|
|
):
|
|
raise ValueError(f"{field} must be a canonical workflow-relative key")
|
|
return text
|
|
|
|
|
|
def contains_unsafe_location(value: str) -> bool:
|
|
stripped = value.strip()
|
|
variants = [stripped]
|
|
for _ in range(2):
|
|
decoded = unquote(variants[-1])
|
|
if decoded == variants[-1]:
|
|
break
|
|
variants.append(decoded)
|
|
for candidate in variants:
|
|
if _SECRET_ASSIGNMENT_PATTERN.search(candidate):
|
|
return True
|
|
fragments = (candidate,) + tuple(
|
|
fragment
|
|
for fragment in re.split(r"[\s=\"'()\[\]{}<>;,]+", candidate)
|
|
if fragment
|
|
)
|
|
for fragment in fragments:
|
|
lower = fragment.lower()
|
|
normalized = fragment.replace("\\", "/")
|
|
segments = normalized.split("/")
|
|
looks_relative = (
|
|
len(segments) >= 3
|
|
and all(_PATH_SEGMENT_PATTERN.fullmatch(segment) for segment in segments)
|
|
) or (
|
|
len(segments) >= 2
|
|
and all(_PATH_SEGMENT_PATTERN.fullmatch(segment) for segment in segments)
|
|
and bool(_FILE_NAME_PATTERN.fullmatch(segments[-1]))
|
|
)
|
|
if (
|
|
bool(_URI_SCHEME_PATTERN.match(fragment))
|
|
or lower.startswith(tuple(prefix.lower() for prefix in _FORBIDDEN_LOCATOR_PREFIXES))
|
|
or fragment.startswith(("./", "../", "~/", "\\\\"))
|
|
or bool(_WINDOWS_PATH_PATTERN.match(fragment))
|
|
or any(segment == ".." for segment in segments)
|
|
or looks_relative
|
|
or (
|
|
_PATH_ASSIGNMENT_PATTERN.search(candidate) is not None
|
|
and ("/" in fragment or "\\" in fragment)
|
|
)
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def require_safe_message(value: object, field: str, *, max_length: int = 512) -> str:
|
|
text = require_string(value, field, max_length=max_length)
|
|
tokens = (text,) + tuple(text.split())
|
|
if any(contains_unsafe_location(token.strip("'\"()[]{}<>,;")) for token in tokens):
|
|
raise ValueError(f"{field} must not contain a URI or local path")
|
|
return text
|
|
|
|
|
|
def require_opaque_resource_id(value: object, field: str) -> str:
|
|
"""Validate a non-secret resource handle without treating it as a locator."""
|
|
text = require_string(value, field, max_length=160)
|
|
if (
|
|
contains_unsafe_location(text)
|
|
or "/" in text
|
|
or "\\" in text
|
|
or "," in text
|
|
or any(character.isspace() for character in text)
|
|
):
|
|
raise ValueError(f"{field} must be an opaque single resource identifier")
|
|
return text
|
|
|
|
|
|
def require_finite_number(value: object, field: str) -> int | float:
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise ValueError(f"{field} must be a finite number")
|
|
if isinstance(value, int):
|
|
if abs(value).bit_length() > 4096:
|
|
raise ValueError(f"{field} exceeds the 4096-bit integer bound")
|
|
return value
|
|
if not math.isfinite(value):
|
|
raise ValueError(f"{field} must be a finite number")
|
|
return value
|
|
|
|
|
|
def freeze_json(
|
|
value: object,
|
|
field: str,
|
|
*,
|
|
forbid_locations: bool = False,
|
|
_depth: int = 0,
|
|
) -> Any:
|
|
"""Return an immutable deep copy of a JSON value.
|
|
|
|
Scientific task parameters use ``forbid_locations`` so durable payloads
|
|
cannot smuggle worker-local paths or transport URLs. Manifests and verifier
|
|
evidence use ordinary JSON validation because JSON Schema keywords and
|
|
sanitized references may legitimately contain URI-shaped strings.
|
|
"""
|
|
if _depth > 64:
|
|
raise ValueError(f"{field} nesting exceeds 64 levels")
|
|
if value is None or isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, int):
|
|
if abs(value).bit_length() > 4096:
|
|
raise ValueError(f"{field} contains an integer above the 4096-bit JSON bound")
|
|
return value
|
|
if isinstance(value, str):
|
|
if any(ord(character) < 32 for character in value):
|
|
raise ValueError(f"{field} must not contain control characters")
|
|
if forbid_locations and contains_unsafe_location(value):
|
|
raise ValueError(f"{field} must not contain a URI or local path")
|
|
return value
|
|
if isinstance(value, float):
|
|
if not math.isfinite(value):
|
|
raise ValueError(f"{field} must not contain NaN or infinity")
|
|
return value
|
|
if isinstance(value, Mapping):
|
|
frozen: dict[str, Any] = {}
|
|
for key, child in value.items():
|
|
if not isinstance(key, str):
|
|
raise ValueError(f"{field} must use string object keys")
|
|
frozen[key] = freeze_json(
|
|
child,
|
|
f"{field}.{key}",
|
|
forbid_locations=forbid_locations,
|
|
_depth=_depth + 1,
|
|
)
|
|
return MappingProxyType(frozen)
|
|
if isinstance(value, (list, tuple)):
|
|
return tuple(
|
|
freeze_json(
|
|
child,
|
|
f"{field}[]",
|
|
forbid_locations=forbid_locations,
|
|
_depth=_depth + 1,
|
|
)
|
|
for child in value
|
|
)
|
|
raise ValueError(f"{field} must contain only JSON-compatible values")
|
|
|
|
|
|
def freeze_json_mapping(
|
|
value: object,
|
|
field: str,
|
|
*,
|
|
forbid_locations: bool = False,
|
|
) -> Mapping[str, Any]:
|
|
mapping = require_mapping(value, field)
|
|
frozen = freeze_json(mapping, field, forbid_locations=forbid_locations)
|
|
assert isinstance(frozen, Mapping)
|
|
return frozen
|
|
|
|
|
|
def thaw_json(value: object) -> Any:
|
|
if isinstance(value, Mapping):
|
|
return {key: thaw_json(child) for key, child in value.items()}
|
|
if isinstance(value, tuple):
|
|
return [thaw_json(child) for child in value]
|
|
return value
|
|
|
|
|
|
def canonical_json(value: object) -> str:
|
|
return json.dumps(thaw_json(value), sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
|
|
|
|
def parse_release(value: object, field: str = "version") -> tuple[int, int, int]:
|
|
text = require_string(value, field, max_length=32)
|
|
match = _VERSION_PATTERN.fullmatch(text)
|
|
if match is None:
|
|
raise ValueError(f"{field} must contain one to three numeric release components")
|
|
return tuple(int(part or 0) for part in match.groups()) # type: ignore[return-value]
|
|
|
|
|
|
def validate_version_range(expression: object, field: str) -> str:
|
|
text = require_string(expression, field, max_length=128)
|
|
clauses = [clause.strip() for clause in text.split(",")]
|
|
if not clauses or any(not clause for clause in clauses):
|
|
raise ValueError(f"{field} must be an explicit version range")
|
|
canonical_clauses: list[str] = []
|
|
for clause in clauses:
|
|
match = _VERSION_CLAUSE_PATTERN.fullmatch(clause)
|
|
if match is None:
|
|
raise ValueError(f"{field} must use ==, >=, <=, >, or < clauses")
|
|
bound = match.group(2).strip()
|
|
parse_release(bound, field)
|
|
canonical_clauses.append(match.group(1) + bound)
|
|
return ",".join(canonical_clauses)
|
|
|
|
|
|
def version_in_range(version: object, expression: str) -> bool:
|
|
candidate = parse_release(version)
|
|
for clause in expression.split(","):
|
|
match = _VERSION_CLAUSE_PATTERN.fullmatch(clause)
|
|
assert match is not None
|
|
operator, raw_bound = match.groups()
|
|
bound = parse_release(raw_bound)
|
|
if operator == "==" and candidate != bound:
|
|
return False
|
|
if operator == ">=" and candidate < bound:
|
|
return False
|
|
if operator == "<=" and candidate > bound:
|
|
return False
|
|
if operator == ">" and candidate <= bound:
|
|
return False
|
|
if operator == "<" and candidate >= bound:
|
|
return False
|
|
return True
|
|
|
|
|
|
def enum_value(enum_type: type[Enum], value: object, field: str) -> Any:
|
|
try:
|
|
return enum_type(value)
|
|
except (TypeError, ValueError) as error:
|
|
allowed = ", ".join(member.value for member in enum_type)
|
|
raise ValueError(f"{field} must be one of: {allowed}") from error
|