# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/which-invariants-prevent-a-delegated-authorization-chain-from-silently-widening-scope-at-the-second-hop.md
# Remnant Warrant:    https://corpusfetch.com/c/which-invariants-prevent-a-delegated-authorization-chain-from-silently-widening-scope-at-the-second-hop.warrant.json
# Markdown SHA-256:   0227982653526111f9407df5359fb0860370bf319b665f923009f748c63288f1
# Code AST SHA-256:   a0fff8200126b85cd4913f753b3df8dbc41b812d1133f4377450e68f38b502f0
# Verified Classes:   DelegationWideningError, Credential
# Defensive Guards:   4 fail-closed invariants (zero external deps)

import hashlib
from dataclasses import dataclass, field
from typing import FrozenSet


class DelegationWideningError(RuntimeError):
    """Raised when a delegated credential chain widens authority at any hop."""


@dataclass(frozen=True)
class Credential:
    subject: str
    actions: FrozenSet[str]
    resources: FrozenSet[str]
    audiences: FrozenSet[str]
    scope_string: str
    parent_digest: str = ""  # digest of the parent credential this attenuates

    def digest(self) -> str:
        h = hashlib.sha256()
        h.update(self.subject.encode())
        h.update(b"|".join(sorted(self.actions)))
        h.update(b"|".join(sorted(self.resources)))
        h.update(b"|".join(sorted(self.audiences)))
        h.update(self.scope_string.encode())
        h.update(self.parent_digest.encode())
        return h.hexdigest()


def _parse_scope_string(scope: str) -> tuple[FrozenSet[str], FrozenSet[str]]:
    # Legacy string form: "action action resource/pattern ..."
    actions, resources = set(), set()
    for tok in scope.split():
        if "/" in tok or "*" in tok:
            resources.add(tok)
        else:
            actions.add(tok)
    return frozenset(actions), frozenset(resources)


def _subset(child: FrozenSet[str], parent: FrozenSet[str], label: str) -> None:
    extra = child - parent
    if extra:
        raise DelegationWideningError(
            f"{label} widened at hop: child grants {sorted(extra)} "
            f"not present in parent {sorted(parent)}"
        )


def verify_attenuation(child: Credential, parent: Credential) -> None:
    """Enforce total attenuation across every scope representation."""
    if child.parent_digest != parent.digest():
        raise DelegationWideningError(
            "delegation proof is not bound to this parent credential"
        )
    _subset(child.actions, parent.actions, "actions")
    _subset(child.resources, parent.resources, "resources")
    _subset(child.audiences, parent.audiences, "audiences")

    # Attenuation must also hold over the legacy string representation.
    child_actions, child_resources = _parse_scope_string(child.scope_string)
    _subset(child_actions, parent.actions, "scope_string actions")
    _subset(child_resources, parent.resources, "scope_string resources")

    # Any structured action/resource must also appear in the string form;
    # a mismatch means a representation the attenuation logic cannot reconcile.
    if not child.actions <= child_actions:
        raise DelegationWideningError(
            "structured actions not represented in scope_string; "
            "representation collapse detected"
        )


def verify_chain(chain: list[Credential]) -> None:
    if not chain:
        raise DelegationWideningError("empty chain")
    for parent, child in zip(chain, chain[1:]):
        verify_attenuation(child, parent)
