#!/usr/bin/env python3

"""Which applications the desktop portal has recorded an answer for.

Read from xdg-desktop-portal's permission store, which is where an application
that asks through the portal has its answer written down. That is the whole of
what this can control, and the limit is worth stating plainly rather than
implying a protection that does not exist: a native binary opens /dev/video0
directly and no desktop setting stands in its way. What this covers is Flatpaks
and anything else that goes through the portal -- which on this machine is most
of what would ever ask.

Six subjects, mapped onto the store's own layout rather than onto a tidier one:

  camera, microphone   entries of the store's `devices` table, keyed by the
                       device name. Plain yes/no, so they can be toggled.
  background           the `background` table's single `background` entry, also
                       plain yes/no. This is "may run when you closed it".
  screencast           its own table, and remote-desktop likewise -- but their
  remote-desktop       ids are opaque restore tokens, one per remembered
                       session, and the value beside each grant is a structured
                       GVariant describing which monitor or which input devices
                       were shared. Nothing here can rebuild one of those, so
                       these report which application holds grants and offer
                       only to drop them. There is deliberately no code path
                       that Sets them: a toggle that cannot be honoured is worse
                       than no toggle.
  location             listed only. geoclue is absent on this machine, so the
                       table is normally empty and the page omits the section
                       rather than showing an empty one.

A table nothing has asked for is reported as an empty list rather than omitted,
so the page can say "nothing has asked" instead of showing nothing at all.

    panama-permissions snapshot
    panama-permissions set TABLE APP_ID true|false
    panama-permissions forget TABLE APP_ID
"""

from __future__ import annotations

import json
import re
import subprocess
import sys
from dataclasses import dataclass

APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
TABLE_ID = re.compile(r"^[a-z][a-z-]{0,31}$")

# The store's own ids for a remembered session are opaque tokens, so they are
# never accepted from a caller -- only discovered by listing the table.
ENTRY_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$")

ALLOWED = "yes"
DENIED = "no"

SIMPLE = "simple"
STRUCTURED = "structured"
READONLY = "readonly"


@dataclass(frozen=True)
class Table:
    """One subject on the page, and where the store keeps it.

    `portal` is the store's table name, which is not always the subject's name:
    camera and microphone both live in `devices`. `entries` names the store ids
    to look under, or None to discover them by listing the table -- which is the
    only option for screencast and remote-desktop, whose ids are restore tokens.
    `writable` is the id a Set writes to, and is None for everything this
    refuses to write.
    """

    name: str
    portal: str
    entries: tuple[str, ...] | None
    writable: str | None
    kind: str


TABLES: tuple[Table, ...] = (
    Table("camera", "devices", ("camera",), "camera", SIMPLE),
    Table("microphone", "devices", ("microphone",), "microphone", SIMPLE),
    Table("screencast", "screencast", None, None, STRUCTURED),
    Table("remote-desktop", "remote-desktop", None, None, STRUCTURED),
    Table("background", "background", None, "background", SIMPLE),
    Table("location", "location", None, None, READONLY),
)

BY_NAME = {table.name: table for table in TABLES}

# Named once, here, so the refusal below reads as a list membership rather than
# as a chain of conditions someone could later add an exception to.
SIMPLE_TABLES = tuple(table.name for table in TABLES if table.kind == SIMPLE)
REVOKE_ONLY_TABLES = tuple(table.name for table in TABLES if table.kind == STRUCTURED)


class BoundaryError(RuntimeError):
    """A user-visible validation or permission-store failure."""


def run(command: list[str], timeout: float = 15.0) -> subprocess.CompletedProcess:
    try:
        return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
    except FileNotFoundError as error:
        raise BoundaryError("busctl is not available.") from error
    except subprocess.TimeoutExpired as error:
        raise BoundaryError("The permission store did not respond.") from error


def portal_call(method: str, signature: str, *arguments: str) -> dict | None:
    """One call to the permission store, as JSON.

    --json=short rather than busctl's text output, which escapes non-ASCII into
    octal and would mangle an application name.
    """
    result = run([
        "busctl", "--user", "--json=short", "call",
        "org.freedesktop.impl.portal.PermissionStore",
        "/org/freedesktop/impl/portal/PermissionStore",
        "org.freedesktop.impl.portal.PermissionStore",
        method, signature, *arguments,
    ])
    if result.returncode != 0:
        detail = (result.stderr or "").strip()
        # An entry nothing has ever asked for has no row at all, and the store
        # says so as "No entry for camera". Matched on the store's actual words
        # rather than a guess at them.
        lowered = detail.lower()
        if "no entry" in lowered or "not found" in lowered:
            return None
        raise BoundaryError(detail.splitlines()[-1] if detail else "The permission store refused that.")
    try:
        return json.loads(result.stdout or "null")
    except json.JSONDecodeError as error:
        raise BoundaryError("The permission store returned something unreadable.") from error


def available() -> bool:
    result = run(["busctl", "--user", "list"])
    return "org.freedesktop.impl.portal.PermissionStore" in (result.stdout or "")


def entry_ids(table: Table) -> list[str]:
    """The store ids to look under for one subject.

    Fixed for the device entries, discovered for everything else. A discovered
    id that does not look like an id is dropped rather than passed back into the
    store.
    """
    if table.entries is not None:
        return list(table.entries)
    payload = portal_call("List", "s", table.portal)
    if not payload:
        return []
    data = payload.get("data") or []
    listed = data[0] if data and isinstance(data[0], list) else []
    return [str(value) for value in listed if ENTRY_ID.match(str(value))]


def rows_for(table: Table) -> list[dict]:
    """One row per application, folded across every entry in the table.

    screencast keeps a separate entry per remembered session, so an application
    that has shared its screen four times appears four times in the store. The
    page is answering "may this application share your screen", which is one
    question, so the rows are folded by application and `grants` says how many
    stored sessions are behind the row.
    """
    folded: dict[str, dict] = {}
    for entry in entry_ids(table):
        payload = portal_call("Lookup", "ss", table.portal, entry)
        if not payload:
            continue
        data = payload.get("data") or []
        recorded = data[0] if data and isinstance(data[0], dict) else {}
        if not isinstance(recorded, dict):
            continue
        for app_id, permissions in recorded.items():
            values = [str(value) for value in (permissions or [])]
            row = folded.setdefault(str(app_id), {
                "app": str(app_id),
                # Anything that is not an explicit "yes" is treated as withheld:
                # guessing generously about a camera is the wrong way to be wrong.
                "allowed": False,
                "grants": 0,
                "raw": "",
            })
            row["grants"] += 1
            if ALLOWED in values:
                row["allowed"] = True
            if row["raw"] == "":
                row["raw"] = ",".join(values)

    rows = list(folded.values())
    rows.sort(key=lambda row: row["app"].casefold())
    return rows


def snapshot() -> dict:
    if not available():
        return {
            "available": False,
            "tables": {table.name: [] for table in TABLES},
            "simpleTables": list(SIMPLE_TABLES),
            "revokeOnlyTables": list(REVOKE_ONLY_TABLES),
            "error": "The desktop portal's permission store is not running.",
        }

    tables: dict[str, list[dict]] = {}
    problems: list[str] = []
    for table in TABLES:
        # One unreadable table must not take the other five down with it: a page
        # that shows nothing because location is broken is worse than a page
        # that shows five subjects and says location could not be read.
        try:
            tables[table.name] = rows_for(table)
        except BoundaryError as error:
            tables[table.name] = []
            problems.append(f"{table.name}: {error}")

    return {
        "available": True,
        "tables": tables,
        "simpleTables": list(SIMPLE_TABLES),
        "revokeOnlyTables": list(REVOKE_ONLY_TABLES),
        "error": "; ".join(problems),
    }


def require(pattern: re.Pattern[str], value: str, message: str) -> str:
    if not pattern.match(value or ""):
        raise BoundaryError(message)
    return value


def named(name: str) -> Table:
    require(TABLE_ID, name, "That is not a permission table.")
    table = BY_NAME.get(name)
    if table is None:
        raise BoundaryError("That is not a permission table this manages.")
    return table


def set_permission(name: str, app: str, allowed: bool) -> None:
    """Record an answer. Only for the subjects whose value is a plain yes or no.

    screencast and remote-desktop are refused here and have no other route to a
    Set anywhere in this file. Their stored value is a structured description of
    a session -- which monitor, which input devices -- and writing a bare "yes"
    over it would leave the store holding a grant the portal cannot restore.
    Dropping the grant is the honest operation, so that is the only one offered.
    """
    table = named(name)
    require(APP_ID, app, "That is not an application.")
    if table.name not in SIMPLE_TABLES or table.writable is None:
        if table.kind == STRUCTURED:
            raise BoundaryError(
                f"A {table.name} grant describes a whole session, so it can only "
                "be revoked, not switched on and off.")
        raise BoundaryError(f"{table.name} permissions are shown, not changed, here.")
    # Permissions are an array of strings, so busctl needs the element count
    # before the element -- "1 yes", not "yes".
    portal_call("SetPermission", "sbssas", table.portal, "true", table.writable, app,
                "1", ALLOWED if allowed else DENIED)


def forget(name: str, app: str) -> None:
    """Drop the recorded answer, so the application is asked again next time.

    Every entry the application appears under, because one application can hold
    several remembered screencast sessions and dropping one of them would leave
    the row on the page looking unchanged.
    """
    table = named(name)
    require(APP_ID, app, "That is not an application.")
    dropped = 0
    for entry in entry_ids(table):
        payload = portal_call("Lookup", "ss", table.portal, entry)
        if not payload:
            continue
        data = payload.get("data") or []
        recorded = data[0] if data and isinstance(data[0], dict) else {}
        if not isinstance(recorded, dict) or app not in recorded:
            continue
        portal_call("DeletePermission", "sss", table.portal, entry, app)
        dropped += 1
    if dropped == 0:
        raise BoundaryError("There was no recorded answer to forget.")


def main(arguments: list[str]) -> int:
    try:
        if arguments == ["snapshot"]:
            print(json.dumps(snapshot(), separators=(",", ":")))
            return 0

        if len(arguments) == 4 and arguments[0] == "set":
            if arguments[3] not in ("true", "false"):
                raise BoundaryError("That is not true or false.")
            set_permission(arguments[1], arguments[2], arguments[3] == "true")
        elif len(arguments) == 3 and arguments[0] == "forget":
            forget(arguments[1], arguments[2])
        else:
            raise BoundaryError(
                "Usage: panama-permissions snapshot | set TABLE APP true|false | "
                "forget TABLE APP")
    except BoundaryError as error:
        try:
            state = snapshot()
        except BoundaryError:
            state = {
                "available": False,
                "tables": {table.name: [] for table in TABLES},
                "simpleTables": list(SIMPLE_TABLES),
                "revokeOnlyTables": list(REVOKE_ONLY_TABLES),
            }
        state["error"] = str(error)
        print(json.dumps(state, separators=(",", ":")))
        return 0

    print(json.dumps(snapshot(), separators=(",", ":")))
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
