244 lines
9.8 KiB
Python
244 lines
9.8 KiB
Python
"""Validation and hashing for the versioned isolated changeset contract."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
from .errors import DocForgeError
|
|
from .models import Node, ProjectService
|
|
from .project import project_root_fingerprint
|
|
|
|
ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{1,127}")
|
|
HASH_PATTERN = re.compile(r"[0-9a-f]{64}")
|
|
CHANGESET_KEYS = frozenset(
|
|
{
|
|
"schema_version",
|
|
"changeset_id",
|
|
"project_id",
|
|
"root_fingerprint",
|
|
"base_revision",
|
|
"base_source_hash",
|
|
"creator",
|
|
"operations",
|
|
}
|
|
)
|
|
OPERATION_KEYS = frozenset(
|
|
{
|
|
"sequence",
|
|
"operation",
|
|
"node_id",
|
|
"expected_content_hash",
|
|
"target_source",
|
|
"metadata",
|
|
"content",
|
|
"relationship_changes",
|
|
"rationale",
|
|
}
|
|
)
|
|
METADATA_KEYS = frozenset(
|
|
{"title", "family", "authority", "status", "tags", "summary", "source_anchor"}
|
|
)
|
|
REQUIRED_CREATE_METADATA = frozenset({"title", "family", "authority", "status", "tags", "summary"})
|
|
RELATIONSHIP_KEYS = frozenset({"action", "source_id", "relation", "target_id"})
|
|
OPERATIONS = frozenset({"create", "update", "move", "delete"})
|
|
|
|
|
|
def canonical_bytes(document: dict[str, Any]) -> bytes:
|
|
return json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def document_hash(document: dict[str, Any]) -> str:
|
|
return hashlib.sha256(canonical_bytes(document)).hexdigest()
|
|
|
|
|
|
def node_metadata(node: Node) -> dict[str, Any]:
|
|
return {
|
|
"title": node.title,
|
|
"family": node.family,
|
|
"authority": node.authority,
|
|
"status": node.status,
|
|
"tags": list(node.tags),
|
|
"summary": node.summary,
|
|
"source_anchor": node.source_anchor,
|
|
}
|
|
|
|
|
|
def edge_key(change: dict[str, Any]) -> tuple[str, str, str]:
|
|
return change["source_id"], change["relation"], change["target_id"]
|
|
|
|
|
|
def edge_dict(edge: tuple[str, str, str], *, action: str) -> dict[str, str]:
|
|
return {
|
|
"action": action,
|
|
"source_id": edge[0],
|
|
"relation": edge[1],
|
|
"target_id": edge[2],
|
|
}
|
|
|
|
|
|
def validate_id(value: object, field: str) -> str:
|
|
if not isinstance(value, str) or ID_PATTERN.fullmatch(value) is None:
|
|
raise DocForgeError("invalid_id", f"{field} must be a stable ID", field=field)
|
|
return value
|
|
|
|
|
|
def validate_hash(value: object, field: str) -> str:
|
|
if not isinstance(value, str) or HASH_PATTERN.fullmatch(value) is None:
|
|
raise DocForgeError("invalid_hash", f"{field} must be a lowercase SHA-256 hash")
|
|
return value
|
|
|
|
|
|
def normalize_operation(operation: dict[str, Any], *, sequence: int) -> dict[str, Any]:
|
|
allowed = OPERATION_KEYS if "sequence" in operation else OPERATION_KEYS - {"sequence"}
|
|
unknown = sorted(set(operation) - allowed)
|
|
if unknown:
|
|
raise DocForgeError("invalid_operation", "Operation has unknown fields", fields=unknown)
|
|
missing = sorted((OPERATION_KEYS - {"sequence"}) - set(operation))
|
|
if missing:
|
|
raise DocForgeError("invalid_operation", "Operation is missing fields", fields=missing)
|
|
kind = operation["operation"]
|
|
if kind not in OPERATIONS:
|
|
raise DocForgeError("invalid_operation", "Operation type is invalid", operation=kind)
|
|
validate_id(operation["node_id"], "node_id")
|
|
rationale = operation["rationale"]
|
|
if not isinstance(rationale, str) or not rationale.strip():
|
|
raise DocForgeError("invalid_operation", "Operation rationale must be non-empty")
|
|
relationships = operation["relationship_changes"]
|
|
if not isinstance(relationships, list):
|
|
raise DocForgeError("invalid_operation", "relationship_changes must be an array")
|
|
normalized_relationships: list[dict[str, str]] = []
|
|
for change_value in cast(list[object], relationships):
|
|
if not isinstance(change_value, dict):
|
|
raise DocForgeError(
|
|
"invalid_operation", "Relationship changes require exact structured fields"
|
|
)
|
|
change = cast(dict[str, object], change_value)
|
|
if set(change) != set(RELATIONSHIP_KEYS):
|
|
raise DocForgeError(
|
|
"invalid_operation", "Relationship changes require exact structured fields"
|
|
)
|
|
action = change["action"]
|
|
if not isinstance(action, str) or action not in {"add", "remove"}:
|
|
raise DocForgeError("invalid_operation", "Relationship action is invalid")
|
|
source_id = validate_id(change["source_id"], "source_id")
|
|
relation = validate_id(change["relation"], "relation")
|
|
target_id = validate_id(change["target_id"], "target_id")
|
|
normalized_relationships.append(
|
|
{
|
|
"action": action,
|
|
"source_id": source_id,
|
|
"relation": relation,
|
|
"target_id": target_id,
|
|
}
|
|
)
|
|
keys = [
|
|
(item["action"], item["source_id"], item["relation"], item["target_id"])
|
|
for item in normalized_relationships
|
|
]
|
|
if len(keys) != len(set(keys)):
|
|
raise DocForgeError("invalid_operation", "Relationship changes contain duplicates")
|
|
metadata = operation["metadata"]
|
|
if metadata is not None and not isinstance(metadata, dict):
|
|
raise DocForgeError("invalid_operation", "metadata must be an object or null")
|
|
if isinstance(metadata, dict):
|
|
metadata = cast(dict[str, object], metadata)
|
|
unknown_metadata = sorted(set(metadata) - METADATA_KEYS)
|
|
if unknown_metadata:
|
|
raise DocForgeError(
|
|
"invalid_operation", "Node metadata has unknown fields", fields=unknown_metadata
|
|
)
|
|
content = operation["content"]
|
|
if content is not None and not isinstance(content, str):
|
|
raise DocForgeError("invalid_operation", "content must be text or null")
|
|
target = operation["target_source"]
|
|
if target is not None and not isinstance(target, str):
|
|
raise DocForgeError("invalid_operation", "target_source must be text or null")
|
|
expected = operation["expected_content_hash"]
|
|
if expected is not None:
|
|
validate_hash(expected, "expected_content_hash")
|
|
return {
|
|
"sequence": sequence,
|
|
"operation": kind,
|
|
"node_id": operation["node_id"],
|
|
"expected_content_hash": expected,
|
|
"target_source": target,
|
|
"metadata": metadata,
|
|
"content": content,
|
|
"relationship_changes": sorted(
|
|
normalized_relationships,
|
|
key=lambda item: (
|
|
item["source_id"],
|
|
item["relation"],
|
|
item["target_id"],
|
|
item["action"],
|
|
),
|
|
),
|
|
"rationale": rationale.strip(),
|
|
}
|
|
|
|
|
|
def validate_document(project: ProjectService, document: Any, *, path: Path) -> dict[str, Any]:
|
|
if not isinstance(document, dict):
|
|
raise DocForgeError(
|
|
"invalid_changeset", "Changeset has missing or unknown fields", path=path.name
|
|
)
|
|
document = cast(dict[str, Any], document)
|
|
if set(document) != set(CHANGESET_KEYS):
|
|
raise DocForgeError(
|
|
"invalid_changeset", "Changeset has missing or unknown fields", path=path.name
|
|
)
|
|
if document["schema_version"] != 1:
|
|
raise DocForgeError("invalid_changeset", "Changeset schema_version must be 1")
|
|
validate_id(document["changeset_id"], "changeset_id")
|
|
if path.name != f"{document['changeset_id']}.json":
|
|
raise DocForgeError("invalid_changeset", "Changeset ID does not match its file name")
|
|
descriptor = project.descriptor
|
|
identity = {
|
|
"project_id": descriptor.project_id,
|
|
"root_fingerprint": project_root_fingerprint(descriptor.root),
|
|
}
|
|
for field, expected in identity.items():
|
|
if document[field] != expected:
|
|
raise DocForgeError(
|
|
"changeset_identity_mismatch",
|
|
"Changeset belongs to another project",
|
|
field=field,
|
|
expected=expected,
|
|
actual=document[field],
|
|
)
|
|
if not isinstance(document["base_revision"], str) or not document["base_revision"]:
|
|
raise DocForgeError("invalid_changeset", "base_revision must be non-empty")
|
|
validate_hash(document["base_source_hash"], "base_source_hash")
|
|
validate_id(document["creator"], "creator")
|
|
writers = {writer.writer_id for writer in descriptor.proposal_writers}
|
|
if document["creator"] not in writers:
|
|
raise DocForgeError(
|
|
"unknown_writer", "Changeset creator is not configured", writer=document["creator"]
|
|
)
|
|
operations = document["operations"]
|
|
if not isinstance(operations, list):
|
|
raise DocForgeError("invalid_changeset", "operations must be an array")
|
|
typed_operations = cast(list[object], operations)
|
|
if len(typed_operations) > descriptor.limits.max_changeset_operations:
|
|
raise DocForgeError("changeset_operation_limit", "Changeset has too many operations")
|
|
normalized: list[dict[str, Any]] = []
|
|
for index, operation_value in enumerate(typed_operations, start=1):
|
|
if not isinstance(operation_value, dict):
|
|
raise DocForgeError(
|
|
"invalid_changeset", "Stored operation has missing or unknown fields"
|
|
)
|
|
operation = cast(dict[str, Any], operation_value)
|
|
if set(operation) != set(OPERATION_KEYS):
|
|
raise DocForgeError(
|
|
"invalid_changeset", "Stored operation has missing or unknown fields"
|
|
)
|
|
if operation["sequence"] != index:
|
|
raise DocForgeError("invalid_changeset", "Operation sequence is not contiguous")
|
|
normalized.append(normalize_operation(operation, sequence=index))
|
|
if len({item["node_id"] for item in normalized}) != len(normalized):
|
|
raise DocForgeError("duplicate_operation", "A changeset may touch a node only once")
|
|
return {**document, "operations": normalized}
|