src/ail_ref/cli.py

Read-only source from the unchanged 0.3.4 kit. Existing license terms apply.

from __future__ import annotations

import argparse
import json
from pathlib import Path

from .anchor import compare_public_anchor, verify_with_trusted_https_anchor
from .canonical import canonicalize, pretty
from .parser import ParseError, parse
from .packet import load_and_verify_packet, verify_trusted_fingerprint
from .passport import inspect_passport
from .registry import load_registry
from .validator import validate


def read_document(path: str):
    return parse(Path(path).read_text(encoding="utf-8"))


def command_validate(args) -> int:
    try:
        document = read_document(args.file)
    except (OSError, ParseError) as exc:
        print(json.dumps({"valid": False, "issues": [{"code": "E01", "message": str(exc)}]}, indent=2))
        return 1
    try:
        registry = load_registry(args.registry) if args.registry else None
    except (OSError, ValueError) as exc:
        print(json.dumps({"valid": False, "issues": [{"code": "E03", "message": str(exc)}]}, indent=2))
        return 1
    issues = validate(document, strict_registry=args.strict_registry, registry=registry)
    print(json.dumps({"valid": not issues, "issues": [issue.__dict__ for issue in issues]}, indent=2))
    return 0 if not issues else 1


def command_normalize(args) -> int:
    try:
        document = read_document(args.file)
    except (OSError, ParseError) as exc:
        print(f"E01 {exc}")
        return 1
    print(pretty(document) if args.pretty else canonicalize(document))
    return 0


def command_passport(args) -> int:
    try:
        result = inspect_passport(read_document(args.file))
    except (OSError, ParseError, ValueError) as exc:
        print(json.dumps({"status": "ail:rejected", "reason": str(exc)}, indent=2))
        return 1
    print(json.dumps(result.__dict__, indent=2))
    return 0 if result.status != "ail:rejected" else 1


def command_verify_packet(args) -> int:
    try:
        if args.trusted_fingerprint:
            if args.trusted_anchor_url or args.anchor_file:
                raise ValueError("Use fingerprint verification or anchor verification in separate calls")
            packet = json.loads(Path(args.file).read_text(encoding="utf-8"))
            result = verify_trusted_fingerprint(packet, args.trusted_fingerprint)
        elif args.trusted_anchor_url:
            packet = json.loads(Path(args.file).read_text(encoding="utf-8"))
            if args.anchor_file:
                anchor = json.loads(Path(args.anchor_file).read_text(encoding="utf-8"))
                result = compare_public_anchor(packet, anchor, args.trusted_anchor_url)
            else:
                result = verify_with_trusted_https_anchor(packet, args.trusted_anchor_url)
        elif args.anchor_file:
            raise ValueError("--anchor-file requires --trusted-anchor-url")
        else:
            result = load_and_verify_packet(args.file)
    except (OSError, ValueError, json.JSONDecodeError) as exc:
        print(json.dumps({"valid": False, "signature_status": "ail:rejected", "reason": str(exc)}, indent=2))
        return 1
    data = result if isinstance(result, dict) else result.__dict__
    print(json.dumps(data, indent=2))
    return 0 if data["valid"] else 1


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="ail", description="AIL 0.2 reference interpreter")
    subparsers = parser.add_subparsers(dest="command", required=True)

    validate_parser = subparsers.add_parser("validate", help="parse and validate an AIL document")
    validate_parser.add_argument("file")
    validate_parser.add_argument("--strict-registry", action="store_true")
    validate_parser.add_argument("--registry", help="path to a registry JSON file")
    validate_parser.set_defaults(func=command_validate)

    normalize_parser = subparsers.add_parser("normalize", help="emit canonical AIL text")
    normalize_parser.add_argument("file")
    normalize_parser.add_argument("--pretty", action="store_true")
    normalize_parser.set_defaults(func=command_normalize)

    passport_parser = subparsers.add_parser("passport", help="evaluate an AIL passport")
    passport_parser.add_argument("file")
    passport_parser.set_defaults(func=command_passport)

    packet_parser = subparsers.add_parser("verify-packet", help="verify an AIL 0.3 JSON identity packet")
    packet_parser.add_argument("file")
    packet_parser.add_argument("--trusted-anchor-url", help="independently selected HTTPS anchor URL; fetches it unless --anchor-file is given")
    packet_parser.add_argument("--trusted-fingerprint", help="expected sha256: fingerprint obtained independently; mismatch exits with failure")
    packet_parser.add_argument("--anchor-file", help="compare a supplied anchor file only; this does not prove HTTPS provenance")
    packet_parser.set_defaults(func=command_verify_packet)
    return parser


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())