| # Copyright 2026 The Fuchsia Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Command-line interface for driver-lab host operations. | |
| Structured JSON results go to stdout; diagnostics go to stderr. Exit | |
| categories follow the host specification: 0 for success, 2 for local | |
| argument, plan, or permission errors; `run` returns the run's exit | |
| category, 7 when evidence cannot be persisted, and 4 when interrupted. | |
| """ | |
| import argparse | |
| import asyncio | |
| import dataclasses | |
| import datetime | |
| import json | |
| import sys | |
| from collections.abc import Sequence | |
| from pathlib import Path | |
| from typing import Any | |
| from driver_lab.api import ( | |
| EXIT_ACTIVATION, | |
| EXIT_EVIDENCE, | |
| EXIT_OPERATION, | |
| DriverLab, | |
| ) | |
| from driver_lab.consent import ConsentDecision | |
| from driver_lab.discovery import ( | |
| DEFAULT_PROXY_DRIVER_URL, | |
| DiscoveryError, | |
| NodeDiscovery, | |
| ProxyActivator, | |
| ) | |
| from driver_lab.evidence import EvidenceError | |
| from driver_lab.models import AccessClass, AccessRequest, Decision, ReadGrant | |
| from driver_lab.permissions import ( | |
| GrantStoreError, | |
| Outcome, | |
| add_grant, | |
| load_grants, | |
| resolve, | |
| revoke_grant, | |
| ) | |
| from driver_lab.plans import PlanError, plan_digest, validate_plan | |
| from driver_lab.transport import ( | |
| AllowRule, | |
| ProxyTransport, | |
| SessionContext, | |
| SessionMode, | |
| TransportError, | |
| ) | |
| EXIT_SUCCESS = 0 | |
| EXIT_ERROR = 2 | |
| def _grant_row(grant: ReadGrant) -> dict[str, object]: | |
| row = dataclasses.asdict(grant) | |
| row["access"] = grant.access.value | |
| row["decision"] = grant.decision.value | |
| row["grant_id"] = grant.grant_id | |
| return row | |
| def _emit(payload: dict[str, object]) -> None: | |
| print(json.dumps(payload, sort_keys=True, indent=2)) | |
| def _load_plan(path: Path) -> dict[str, object]: | |
| with path.open("r") as file: | |
| loaded = json.load(file) | |
| if not isinstance(loaded, dict): | |
| raise PlanError("plan file must contain a JSON object") | |
| return loaded | |
| class _StdinConsent: | |
| """Interactive consent on the controlling terminal. | |
| Prints the exact access rule and the side-effect warning to stderr | |
| (stdout stays reserved for JSON results) and reads one decision. | |
| """ | |
| _CHOICES = { | |
| "a": ConsentDecision.ALLOW_ONCE, | |
| "A": ConsentDecision.ALWAYS_ALLOW, | |
| "d": ConsentDecision.DENY_ONCE, | |
| "D": ConsentDecision.ALWAYS_DENY, | |
| } | |
| async def request_consent( | |
| self, request: AccessRequest, warning: str | |
| ) -> ConsentDecision: | |
| print( | |
| f"Consent required: {request.access.value} {request.resource} " | |
| f"offset {request.offset:#x} width {request.width} " | |
| f"(node {request.node_id}, digest {request.resource_digest})", | |
| file=sys.stderr, | |
| ) | |
| print(warning, file=sys.stderr) | |
| while True: | |
| print( | |
| "[a] allow once [A] always allow [d] deny once [D] always deny: ", | |
| end="", | |
| file=sys.stderr, | |
| flush=True, | |
| ) | |
| choice = sys.stdin.readline().strip() | |
| decision = self._CHOICES.get(choice) | |
| if decision is not None: | |
| return decision | |
| print(f"unrecognized choice: {choice!r}", file=sys.stderr) | |
| def _permissions_list(args: argparse.Namespace) -> int: | |
| grants = load_grants(args.grants) | |
| _emit({"grants": [_grant_row(grant) for grant in grants]}) | |
| return EXIT_SUCCESS | |
| def _permissions_revoke(args: argparse.Namespace) -> int: | |
| if not revoke_grant(args.grants, args.grant_id): | |
| print( | |
| json.dumps({"error": f"no grant with id {args.grant_id}"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| _emit({"revoked": args.grant_id}) | |
| return EXIT_SUCCESS | |
| def _permissions_explain(args: argparse.Namespace) -> int: | |
| grants = load_grants(args.grants) | |
| if getattr(args, "plan", None) is not None: | |
| plan = _load_plan(args.plan) | |
| canonical = validate_plan(plan) | |
| target_scope = args.target_scope or canonical["target"]["selector"] | |
| node_id = args.node_id or canonical["node"]["id"] | |
| resource_digest = args.resource_digest or canonical["node"].get( | |
| "expected_resource_digest" | |
| ) | |
| if not resource_digest: | |
| print( | |
| json.dumps( | |
| { | |
| "error": ( | |
| "plan node has no expected_resource_digest; " | |
| "specify --resource-digest or include it in the plan" | |
| ) | |
| } | |
| ), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| requests: list[tuple[int, AccessRequest]] = [] | |
| for index, op in enumerate(canonical["operations"]): | |
| kind = op["kind"] | |
| if kind in ("mmio_read32", "state_read32"): | |
| requests.append( | |
| ( | |
| index, | |
| AccessRequest( | |
| target_scope=target_scope, | |
| node_id=node_id, | |
| resource_digest=resource_digest, | |
| resource=op["resource"], | |
| offset=op["offset"], | |
| width=4, | |
| access=AccessClass.READ_ONCE, | |
| ), | |
| ) | |
| ) | |
| elif kind == "mmio_snapshot32": | |
| for item in op["items"]: | |
| requests.append( | |
| ( | |
| index, | |
| AccessRequest( | |
| target_scope=target_scope, | |
| node_id=node_id, | |
| resource_digest=resource_digest, | |
| resource=item["resource"], | |
| offset=item["offset"], | |
| width=4, | |
| access=AccessClass.READ_ONCE, | |
| ), | |
| ) | |
| ) | |
| elif kind in ("mmio_write32", "knob_write32", "trigger_write32"): | |
| requests.append( | |
| ( | |
| index, | |
| AccessRequest( | |
| target_scope=target_scope, | |
| node_id=node_id, | |
| resource_digest=resource_digest, | |
| resource=op["resource"], | |
| offset=op["offset"], | |
| width=4, | |
| access=AccessClass.WRITE, | |
| ), | |
| ) | |
| ) | |
| elif kind in ("mmio_poll32", "state_poll32"): | |
| requests.append( | |
| ( | |
| index, | |
| AccessRequest( | |
| target_scope=target_scope, | |
| node_id=node_id, | |
| resource_digest=resource_digest, | |
| resource=op["resource"], | |
| offset=op["offset"], | |
| width=4, | |
| access=AccessClass.POLL, | |
| ), | |
| ) | |
| ) | |
| elif kind == "sequence": | |
| for item in op["items"]: | |
| item_kind = item["kind"] | |
| if item_kind in ("mmio_read32", "state_read32"): | |
| requests.append( | |
| ( | |
| index, | |
| AccessRequest( | |
| target_scope=target_scope, | |
| node_id=node_id, | |
| resource_digest=resource_digest, | |
| resource=item["resource"], | |
| offset=item["offset"], | |
| width=4, | |
| access=AccessClass.READ_ONCE, | |
| ), | |
| ) | |
| ) | |
| elif item_kind in ( | |
| "mmio_write32", | |
| "knob_write32", | |
| "trigger_write32", | |
| ): | |
| requests.append( | |
| ( | |
| index, | |
| AccessRequest( | |
| target_scope=target_scope, | |
| node_id=node_id, | |
| resource_digest=resource_digest, | |
| resource=item["resource"], | |
| offset=item["offset"], | |
| width=4, | |
| access=AccessClass.WRITE, | |
| ), | |
| ) | |
| ) | |
| elif item_kind in ("mmio_poll32", "state_poll32"): | |
| requests.append( | |
| ( | |
| index, | |
| AccessRequest( | |
| target_scope=target_scope, | |
| node_id=node_id, | |
| resource_digest=resource_digest, | |
| resource=item["resource"], | |
| offset=item["offset"], | |
| width=4, | |
| access=AccessClass.POLL, | |
| ), | |
| ) | |
| ) | |
| resolutions: list[dict[str, Any]] = [] | |
| all_allowed = True | |
| for op_idx, req in requests: | |
| res = resolve(req, grants) | |
| allowed = res.decided and res.outcome is Outcome.ALLOWED | |
| if not allowed: | |
| all_allowed = False | |
| resolutions.append( | |
| { | |
| "operation": op_idx, | |
| "resource": req.resource, | |
| "offset": req.offset, | |
| "access": req.access.value, | |
| "outcome": res.outcome.value, | |
| "grant_id": res.grant.grant_id if res.grant else None, | |
| } | |
| ) | |
| _emit( | |
| { | |
| "plan_digest": plan_digest(plan), | |
| "all_allowed": all_allowed, | |
| "resolutions": resolutions, | |
| } | |
| ) | |
| return EXIT_SUCCESS | |
| if not ( | |
| args.target_scope | |
| and args.node_id | |
| and args.resource_digest | |
| and args.resource | |
| and args.offset is not None | |
| ): | |
| print( | |
| json.dumps( | |
| { | |
| "error": ( | |
| "either --plan or (--target-scope, --node-id, " | |
| "--resource-digest, --resource, --offset) is required" | |
| ) | |
| } | |
| ), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| request = AccessRequest( | |
| target_scope=args.target_scope, | |
| node_id=args.node_id, | |
| resource_digest=args.resource_digest, | |
| resource=args.resource, | |
| offset=args.offset, | |
| width=args.width, | |
| access=AccessClass(args.access), | |
| ) | |
| resolution = resolve(request, grants) | |
| _emit( | |
| { | |
| "outcome": resolution.outcome.value, | |
| "grant": _grant_row(resolution.grant) if resolution.grant else None, | |
| } | |
| ) | |
| return EXIT_SUCCESS | |
| def _permissions_add(args: argparse.Namespace) -> int: | |
| grant = ReadGrant( | |
| schema_version=1, | |
| target_scope=args.target_scope, | |
| node_id=args.node_id, | |
| resource_digest=args.resource_digest, | |
| resource=args.resource, | |
| offset=args.offset, | |
| width=args.width, | |
| access=AccessClass(args.access), | |
| decision=Decision(args.decision), | |
| approved_at=datetime.datetime.now(datetime.UTC).isoformat(), | |
| approval_source="cli", | |
| ) | |
| add_grant(args.grants, grant) | |
| # The exact persisted rule, so the operator sees precisely what was | |
| # added -- never a region or wildcard. | |
| _emit({"added": _grant_row(grant)}) | |
| return EXIT_SUCCESS | |
| def _connect_transport(moniker: str, target: str | None) -> ProxyTransport: | |
| # Deferred import: the device connection path needs the | |
| # fuchsia-controller runtime, which permissions/plan commands and | |
| # their tests should not load. Tests substitute this seam. | |
| from driver_lab.fidl_transport import LazyFidlProxyTransport | |
| return LazyFidlProxyTransport(moniker=moniker, target=target) | |
| def _connect_discovery(target: str | None = None) -> NodeDiscovery | None: | |
| from driver_lab.discovery import connect_discovery | |
| try: | |
| return connect_discovery(target=target) | |
| except Exception: | |
| return None | |
| def _connect_activator(target: str | None = None) -> ProxyActivator | None: | |
| from driver_lab.discovery import connect_activator | |
| try: | |
| return connect_activator(target=target) | |
| except Exception: | |
| return None | |
| def _list(args: argparse.Namespace) -> int: | |
| discovery = _connect_discovery(target=args.target) | |
| if discovery is None: | |
| print( | |
| json.dumps({"error": "node discovery is not available"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| async def run() -> int: | |
| if getattr(args, "debug_capable", False): | |
| nodes = await discovery.find_debug_capable() | |
| elif args.unclaimed: | |
| nodes = await discovery.find_unclaimed() | |
| else: | |
| nodes = await discovery.list_nodes() | |
| _emit({"nodes": [dataclasses.asdict(n) for n in nodes]}) | |
| return EXIT_SUCCESS | |
| return asyncio.run(run()) | |
| def _describe(args: argparse.Namespace) -> int: | |
| moniker = getattr(args, "moniker", None) | |
| node_id = getattr(args, "node", None) | |
| if not moniker and not node_id: | |
| print( | |
| json.dumps({"error": "either --node or --moniker is required"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| if moniker: | |
| transport = _connect_transport(moniker=moniker, target=args.target) | |
| discovery = _connect_discovery(target=args.target) | |
| async def run_moniker() -> int: | |
| try: | |
| desc = await transport.describe() | |
| except TransportError as exc: | |
| print( | |
| json.dumps({"error": f"describe failed: {exc}"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| payload: dict[str, object] = { | |
| "moniker": moniker, | |
| "description": dataclasses.asdict(desc), | |
| } | |
| if discovery is not None: | |
| node = await discovery.describe_node(node_id or moniker) | |
| if node is not None: | |
| payload["node"] = dataclasses.asdict(node) | |
| _emit(payload) | |
| return EXIT_SUCCESS | |
| return asyncio.run(run_moniker()) | |
| discovery = _connect_discovery(target=args.target) | |
| if discovery is None: | |
| print( | |
| json.dumps({"error": "node discovery is not available"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| async def run() -> int: | |
| assert node_id is not None | |
| node = await discovery.describe_node(node_id) | |
| if node is None: | |
| print( | |
| json.dumps({"error": f"node {node_id!r} not found"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| _emit({"node": dataclasses.asdict(node)}) | |
| return EXIT_SUCCESS | |
| return asyncio.run(run()) | |
| def _inspect(args: argparse.Namespace) -> int: | |
| transport = _connect_transport(moniker=args.moniker, target=args.target) | |
| discovery = _connect_discovery(target=args.target) | |
| async def run() -> int: | |
| try: | |
| desc = await transport.describe() | |
| except TransportError as exc: | |
| print( | |
| json.dumps({"error": f"inspect describe failed: {exc}"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| driver_url: str | None = None | |
| node_payload: dict[str, object] | None = None | |
| if discovery is not None: | |
| lookup_id = getattr(args, "node", None) or args.moniker | |
| node = await discovery.describe_node(lookup_id) | |
| if node is not None: | |
| driver_url = node.bound_driver_url | |
| node_payload = dataclasses.asdict(node) | |
| allowlist: list[AllowRule] = [] | |
| target_res_id: int | None = None | |
| if args.resource is not None and args.offset is not None: | |
| res_info = desc.resource_named(args.resource) | |
| if res_info is None: | |
| print( | |
| json.dumps( | |
| {"error": f"unknown resource {args.resource!r}"} | |
| ), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| target_res_id = res_info.id | |
| allowlist.append( | |
| AllowRule( | |
| resource=res_info.id, | |
| offset=args.offset, | |
| width=4, | |
| access=AccessClass.READ_ONCE, | |
| ) | |
| ) | |
| ctx = SessionContext( | |
| run_id=f"inspect-{args.moniker}", | |
| case_id="inspect", | |
| plan_digest="inspect-session", | |
| ) | |
| read_result: dict[str, object] | None = None | |
| audit_entries: list[dict[str, object]] = [] | |
| try: | |
| session = await transport.open_session( | |
| ctx, | |
| desc.expectations(), | |
| allowlist, | |
| mode=SessionMode.READ_ONLY, | |
| ) | |
| try: | |
| if target_res_id is not None and args.offset is not None: | |
| outcome = await session.read32(target_res_id, args.offset) | |
| read_result = { | |
| "resource": args.resource, | |
| "offset": args.offset, | |
| "value": outcome.value, | |
| "audit_seq": outcome.audit_seq, | |
| "timestamp_ns": outcome.timestamp_ns, | |
| } | |
| page = await session.read_audit(0, 64) | |
| audit_entries = [entry.to_json() for entry in page.entries] | |
| finally: | |
| await session.close() | |
| except Exception as exc: | |
| print( | |
| json.dumps({"error": f"inspect session failed: {exc}"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| payload: dict[str, object] = { | |
| "moniker": args.moniker, | |
| "driver_url": driver_url, | |
| "description": dataclasses.asdict(desc), | |
| "audit": audit_entries, | |
| } | |
| if node_payload is not None: | |
| payload["node"] = node_payload | |
| if read_result is not None: | |
| payload["read"] = read_result | |
| _emit(payload) | |
| return EXIT_SUCCESS | |
| return asyncio.run(run()) | |
| def _direct(args: argparse.Namespace) -> int: | |
| discovery = _connect_discovery(target=args.target) | |
| if discovery is None: | |
| print( | |
| json.dumps({"error": "node discovery is not available"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| async def run() -> int: | |
| node = await discovery.describe_node(args.node) | |
| if node is None: | |
| print( | |
| json.dumps({"error": f"node {args.node!r} not found"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| if not node.has_protocol(args.protocol): | |
| print( | |
| json.dumps( | |
| { | |
| "error": ( | |
| f"node {args.node!r} does not offer protocol {args.protocol!r}; " | |
| f"available: {sorted(node.offers)}" | |
| ) | |
| } | |
| ), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| _emit( | |
| { | |
| "node": args.node, | |
| "protocol": args.protocol, | |
| "status": "verified", | |
| } | |
| ) | |
| return EXIT_SUCCESS | |
| return asyncio.run(run()) | |
| def _bind_proxy(args: argparse.Namespace) -> int: | |
| discovery = _connect_discovery(target=args.target) | |
| activator = _connect_activator(target=args.target) | |
| if discovery is None or activator is None: | |
| print( | |
| json.dumps({"error": "discovery or activator is not available"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| async def run() -> int: | |
| node = await discovery.describe_node(args.node) | |
| if node is None: | |
| print( | |
| json.dumps({"error": f"node {args.node!r} not found"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| if not node.is_unclaimed: | |
| print( | |
| json.dumps( | |
| { | |
| "error": ( | |
| f"node {args.node!r} is not unclaimed; bound to " | |
| f"{node.bound_driver_url}" | |
| ) | |
| } | |
| ), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ACTIVATION | |
| driver_url = args.driver_url or DEFAULT_PROXY_DRIVER_URL | |
| try: | |
| await activator.bind_proxy(args.node, driver_url) | |
| except Exception as exc: | |
| print( | |
| json.dumps({"error": f"bind_proxy failed: {exc}"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ACTIVATION | |
| post_desc = await discovery.describe_node(args.node) | |
| is_bound = not post_desc.is_unclaimed if post_desc else True | |
| _emit( | |
| { | |
| "node": args.node, | |
| "driver_url": driver_url, | |
| "bound": is_bound, | |
| } | |
| ) | |
| return EXIT_SUCCESS | |
| return asyncio.run(run()) | |
| def _end_proxy(args: argparse.Namespace) -> int: | |
| discovery = _connect_discovery(target=args.target) | |
| activator = _connect_activator(target=args.target) | |
| if discovery is None or activator is None: | |
| print( | |
| json.dumps({"error": "discovery or activator is not available"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| async def run() -> int: | |
| driver_url = args.driver_url or DEFAULT_PROXY_DRIVER_URL | |
| try: | |
| await activator.end_proxy(args.node, driver_url) | |
| except Exception as exc: | |
| print( | |
| json.dumps({"error": f"end_proxy failed: {exc}"}), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ACTIVATION | |
| # Verified teardown: verify the node is again unclaimed. | |
| post_desc = await discovery.describe_node(args.node) | |
| if post_desc is not None and not post_desc.is_unclaimed: | |
| print( | |
| json.dumps( | |
| { | |
| "error": ( | |
| f"verified teardown failed: node {args.node!r} is " | |
| f"still bound to {post_desc.bound_driver_url}" | |
| ) | |
| } | |
| ), | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ACTIVATION | |
| _emit( | |
| { | |
| "node": args.node, | |
| "driver_url": driver_url, | |
| "unclaimed": True, | |
| } | |
| ) | |
| return EXIT_SUCCESS | |
| return asyncio.run(run()) | |
| def _run(args: argparse.Namespace) -> int: | |
| plan = _load_plan(args.plan) | |
| transport = _connect_transport(moniker=args.moniker, target=args.target) | |
| discovery = _connect_discovery(target=args.target) | |
| activator = ( | |
| _connect_activator(target=args.target) | |
| if discovery is not None | |
| else None | |
| ) | |
| access_cfg = plan.get("access") | |
| is_in_situ = isinstance(access_cfg, dict) and ( | |
| access_cfg.get("mode") == "in-situ" | |
| or access_cfg.get("activation") == "in-situ" | |
| ) | |
| lab = DriverLab( | |
| transport, | |
| grants_path=args.grants, | |
| evidence_root=args.evidence_dir, | |
| target_scope=args.target_scope, | |
| node_id=args.node_id, | |
| driver_moniker=args.moniker if is_in_situ else None, | |
| consent=_StdinConsent() if args.consent else None, | |
| discovery=discovery, | |
| activator=activator, | |
| ) | |
| result = asyncio.run(lab.run_plan(plan)) | |
| _emit( | |
| { | |
| "exit_category": result.exit_category, | |
| "evidence_dir": str(result.evidence_dir), | |
| "plan_digest": result.plan_digest, | |
| "failure": result.failure, | |
| "reads": [dataclasses.asdict(read) for read in result.reads], | |
| "writes": [dataclasses.asdict(write) for write in result.writes], | |
| "polls": [dataclasses.asdict(poll) for poll in result.polls], | |
| "sequences": [dataclasses.asdict(seq) for seq in result.sequences], | |
| "calls": list(result.calls), | |
| } | |
| ) | |
| return result.exit_category | |
| def _plan_digest(args: argparse.Namespace) -> int: | |
| _emit({"digest": plan_digest(_load_plan(args.plan))}) | |
| return EXIT_SUCCESS | |
| def _plan_validate(args: argparse.Namespace) -> int: | |
| plan = _load_plan(args.plan) | |
| _emit({"digest": plan_digest(plan), "plan": validate_plan(plan)}) | |
| return EXIT_SUCCESS | |
| def _offset(value: str) -> int: | |
| return int(value, 0) | |
| def _build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser(prog="driver-lab") | |
| subcommands = parser.add_subparsers(dest="command", required=True) | |
| permissions = subcommands.add_parser( | |
| "permissions", help="manage persistent operator read grants" | |
| ) | |
| permission_commands = permissions.add_subparsers( | |
| dest="subcommand", required=True | |
| ) | |
| list_parser = permission_commands.add_parser("list", help="list grants") | |
| list_parser.add_argument("--grants", type=Path, required=True) | |
| list_parser.set_defaults(handler=_permissions_list) | |
| revoke_parser = permission_commands.add_parser( | |
| "revoke", help="revoke one grant" | |
| ) | |
| revoke_parser.add_argument("--grants", type=Path, required=True) | |
| revoke_parser.add_argument("--grant-id", required=True) | |
| revoke_parser.set_defaults(handler=_permissions_revoke) | |
| explain_parser = permission_commands.add_parser( | |
| "explain", | |
| help="resolve one access request or a plan against the grants", | |
| ) | |
| explain_parser.add_argument("--grants", type=Path, required=True) | |
| explain_parser.add_argument( | |
| "--plan", type=Path, default=None, help="probe plan to explain" | |
| ) | |
| explain_parser.add_argument("--target-scope", required=False, default=None) | |
| explain_parser.add_argument("--node-id", required=False, default=None) | |
| explain_parser.add_argument( | |
| "--resource-digest", required=False, default=None | |
| ) | |
| explain_parser.add_argument("--resource", required=False, default=None) | |
| explain_parser.add_argument( | |
| "--offset", type=_offset, required=False, default=None | |
| ) | |
| explain_parser.add_argument("--width", type=int, default=4) | |
| explain_parser.add_argument( | |
| "--access", | |
| choices=[access.value for access in AccessClass], | |
| default=AccessClass.READ_ONCE.value, | |
| ) | |
| explain_parser.set_defaults(handler=_permissions_explain) | |
| add_parser = permission_commands.add_parser( | |
| "add", help="persist one exact read grant or denial" | |
| ) | |
| add_parser.add_argument("--grants", type=Path, required=True) | |
| add_parser.add_argument("--target-scope", required=True) | |
| add_parser.add_argument("--node-id", required=True) | |
| add_parser.add_argument("--resource-digest", required=True) | |
| add_parser.add_argument("--resource", required=True) | |
| add_parser.add_argument("--offset", type=_offset, required=True) | |
| add_parser.add_argument("--width", type=int, default=4) | |
| add_parser.add_argument( | |
| "--access", | |
| choices=[access.value for access in AccessClass], | |
| default=AccessClass.READ_ONCE.value, | |
| ) | |
| add_parser.add_argument( | |
| "--decision", | |
| choices=[decision.value for decision in Decision], | |
| required=True, | |
| ) | |
| add_parser.set_defaults(handler=_permissions_add) | |
| list_cmd = subcommands.add_parser( | |
| "list", help="list hardware nodes on target" | |
| ) | |
| list_cmd.add_argument("--target", default=None, help="target nodename") | |
| list_cmd.add_argument( | |
| "--unclaimed", | |
| action="store_true", | |
| help="only list unclaimed nodes eligible for proxy activation", | |
| ) | |
| list_cmd.add_argument( | |
| "--debug-capable", | |
| action="store_true", | |
| help="only list active bound drivers exposing fuchsia.driver.lab.Service", | |
| ) | |
| list_cmd.set_defaults(handler=_list) | |
| describe_cmd = subcommands.add_parser( | |
| "describe", | |
| help="describe a hardware node or embedded driver debug endpoint", | |
| ) | |
| describe_cmd.add_argument( | |
| "--node", required=False, default=None, help="node ID / moniker" | |
| ) | |
| describe_cmd.add_argument( | |
| "--moniker", | |
| required=False, | |
| default=None, | |
| help="live driver moniker exposing fuchsia.driver.lab.Service", | |
| ) | |
| describe_cmd.add_argument("--target", default=None, help="target nodename") | |
| describe_cmd.set_defaults(handler=_describe) | |
| inspect_cmd = subcommands.add_parser( | |
| "inspect", | |
| help="inspect a live driver's embedded fuchsia.driver.lab.Service endpoint in-situ", | |
| ) | |
| inspect_cmd.add_argument( | |
| "--moniker", | |
| required=True, | |
| help="component moniker of the active driver exposing fuchsia.driver.lab.Service", | |
| ) | |
| inspect_cmd.add_argument( | |
| "--node", | |
| required=False, | |
| default=None, | |
| help="optional node ID for discovery metadata", | |
| ) | |
| inspect_cmd.add_argument( | |
| "--resource", | |
| required=False, | |
| default=None, | |
| help="optional MMIO resource name for single-register inspection", | |
| ) | |
| inspect_cmd.add_argument( | |
| "--offset", | |
| type=_offset, | |
| required=False, | |
| default=None, | |
| help="optional 32-bit register byte offset for single-register inspection", | |
| ) | |
| inspect_cmd.add_argument("--target", default=None, help="target nodename") | |
| inspect_cmd.set_defaults(handler=_inspect) | |
| direct_cmd = subcommands.add_parser( | |
| "direct", help="verify direct connection to a published protocol" | |
| ) | |
| direct_cmd.add_argument("--node", required=True, help="node ID / moniker") | |
| direct_cmd.add_argument( | |
| "--protocol", | |
| required=True, | |
| help="published protocol / service selector", | |
| ) | |
| direct_cmd.add_argument("--target", default=None, help="target nodename") | |
| direct_cmd.set_defaults(handler=_direct) | |
| bind_cmd = subcommands.add_parser( | |
| "bind-proxy", help="bind proxy driver to an unclaimed node" | |
| ) | |
| bind_cmd.add_argument("--node", required=True, help="node ID / moniker") | |
| bind_cmd.add_argument( | |
| "--driver-url", | |
| default=DEFAULT_PROXY_DRIVER_URL, | |
| help="proxy driver component URL", | |
| ) | |
| bind_cmd.add_argument("--target", default=None, help="target nodename") | |
| bind_cmd.set_defaults(handler=_bind_proxy) | |
| end_cmd = subcommands.add_parser( | |
| "end-proxy", help="end proxy access and verify node is unclaimed" | |
| ) | |
| end_cmd.add_argument("--node", required=True, help="node ID / moniker") | |
| end_cmd.add_argument( | |
| "--driver-url", | |
| default=DEFAULT_PROXY_DRIVER_URL, | |
| help="proxy driver component URL", | |
| ) | |
| end_cmd.add_argument("--target", default=None, help="target nodename") | |
| end_cmd.set_defaults(handler=_end_proxy) | |
| run_parser = subcommands.add_parser( | |
| "run", help="run one probe plan to a finalized evidence bundle" | |
| ) | |
| run_parser.add_argument("--plan", type=Path, required=True) | |
| run_parser.add_argument("--evidence-dir", type=Path, required=True) | |
| run_parser.add_argument("--grants", type=Path, required=True) | |
| run_parser.add_argument("--target-scope", required=True) | |
| run_parser.add_argument("--node-id", required=True) | |
| run_parser.add_argument( | |
| "--moniker", | |
| required=True, | |
| help="component moniker of the proxy driver on the target", | |
| ) | |
| run_parser.add_argument( | |
| "--target", | |
| default=None, | |
| help="target nodename (default: ffx default target)", | |
| ) | |
| run_parser.add_argument( | |
| "--consent", | |
| action="store_true", | |
| help="prompt interactively for undecided accesses (default: fail closed)", | |
| ) | |
| run_parser.set_defaults(handler=_run) | |
| plan = subcommands.add_parser( | |
| "plan", help="validate and digest probe plans" | |
| ) | |
| plan_commands = plan.add_subparsers(dest="subcommand", required=True) | |
| digest_parser = plan_commands.add_parser( | |
| "digest", help="canonical plan digest" | |
| ) | |
| digest_parser.add_argument("--plan", type=Path, required=True) | |
| digest_parser.set_defaults(handler=_plan_digest) | |
| validate_parser = plan_commands.add_parser( | |
| "validate", help="validate and canonicalize a plan" | |
| ) | |
| validate_parser.add_argument("--plan", type=Path, required=True) | |
| validate_parser.set_defaults(handler=_plan_validate) | |
| return parser | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| """Runs one CLI command; returns the exit category.""" | |
| args = _build_parser().parse_args(argv) | |
| try: | |
| handler = args.handler | |
| result: int = handler(args) | |
| return result | |
| except EvidenceError as error: | |
| print(json.dumps({"error": str(error)}), file=sys.stderr) | |
| return EXIT_EVIDENCE | |
| except KeyboardInterrupt: | |
| # Cancellation inside a run finalizes evidence and reports | |
| # through the run result; reaching here means the interrupt | |
| # arrived outside that path. | |
| print(json.dumps({"error": "interrupted"}), file=sys.stderr) | |
| return EXIT_OPERATION | |
| except ( | |
| GrantStoreError, | |
| PlanError, | |
| DiscoveryError, | |
| OSError, | |
| ValueError, | |
| json.JSONDecodeError, | |
| ) as error: | |
| print(json.dumps({"error": str(error)}), file=sys.stderr) | |
| return EXIT_ERROR | |
| if __name__ == "__main__": | |
| sys.exit(main()) |