#!/usr/bin/env python3

"""The login keyring's lock state, and a way to unlock it.

Why this exists
---------------
GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so
does this desktop -- the PAM stack is GDM's and it works. What GNOME also has,
and a bare Hyprland session does not, is anywhere to SEE that it failed.

It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in
service_method_open_session, seen once here), and when it does, D-Bus activates
a replacement. That replacement never received the login password, so the login
keyring comes back LOCKED in the middle of a session that unlocked it correctly
at login. Everything that stores a secret then starts failing in ways that do
not mention keyrings at all: a mail client that will not authenticate, a git
push that cannot find its key, an integration that reports "not configured".

So this reports the state plainly and offers the one action that fixes it.

Unlocking prompts
-----------------
`unlock` asks the Secret Service to unlock, which raises the gcr password
dialog. That is deliberate: the password is not ours to store or handle, and it
never passes through this script. The dialog is the same one GNOME shows.

Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it
is not hung, it is waiting for a dialog nobody is looking at. That is worth
knowing before debugging one for an hour.

Usage:
  panama-keyring status   -> {"available", "locked", "collections", "daemon"}
  panama-keyring unlock   -> raises the password prompt; prints the new state
"""

import hashlib
import json
import os
import re
import subprocess
import sys


def secrets_name_owner_pid():
    """PID currently owning the org.freedesktop.secrets D-Bus name, if any.

    This is the only reliable way to identify which daemon actually answers
    Secret Service calls right now.
    """
    try:
        import gi

        gi.require_version("Gio", "2.0")
        from gi.repository import Gio, GLib

        bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
        result = bus.call_sync(
            "org.freedesktop.DBus",
            "/org/freedesktop/DBus",
            "org.freedesktop.DBus",
            "GetConnectionUnixProcessID",
            GLib.Variant("(s)", ("org.freedesktop.secrets",)),
            GLib.VariantType("(u)"),
            Gio.DBusCallFlags.NONE,
            -1,
            None,
        )
        return result.unpack()[0]
    except Exception:  # noqa: BLE001 - no name owner is a legitimate state
        return None


def any_keyring_daemon_running():
    try:
        for pid in os.listdir("/proc"):
            if not pid.isdigit():
                continue
            try:
                with open(f"/proc/{pid}/cmdline", "rb") as handle:
                    cmdline = handle.read().decode("utf-8", "replace")
            except OSError:
                continue
            if "gnome-keyring-daemon" in cmdline:
                return True
    except OSError:
        pass
    return False


def daemon_origin():
    """Whether the running secrets daemon came from PAM or from D-Bus activation.

    A D-Bus-activated daemon is the signature of the crash-and-replace case
    above: it is the one that cannot have the login password. PAM's daemon lives
    outside the app slice, so the cgroup tells the two apart.

    A machine can have two gnome-keyring-daemon processes at once -- a
    lingering PAM one alongside its D-Bus-activated replacement -- so which
    process this reports on matters: it must be the one that actually owns
    org.freedesktop.secrets right now, not merely the first one /proc happens
    to enumerate.
    """
    owner_pid = secrets_name_owner_pid()
    if owner_pid is None:
        return "unknown" if any_keyring_daemon_running() else "none"

    try:
        with open(f"/proc/{owner_pid}/cmdline", "rb") as handle:
            cmdline = handle.read().decode("utf-8", "replace")
    except OSError:
        return "unknown"
    if "gnome-keyring-daemon" not in cmdline:
        return "unknown"

    try:
        with open(f"/proc/{owner_pid}/cgroup", "r") as handle:
            cgroup = handle.read()
    except OSError:
        return "unknown"
    if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
        return "dbus"
    return "pam"


def load_service():
    import gi

    gi.require_version("Secret", "1")
    from gi.repository import Secret

    return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None)


def report(service, Secret):
    collections = [
        {"label": c.get_label(), "locked": c.get_locked()}
        for c in service.get_collections()
    ]
    # The login keyring is the one that matters; the others are per-application
    # stores that manage their own unlocking.
    login = next((c for c in collections if c["label"] == "Login"), None)
    return {
        "available": True,
        "locked": bool(login["locked"]) if login else False,
        "hasLogin": login is not None,
        "collections": collections,
        "daemon": daemon_origin(),
        "error": "",
    }


# Attributes worth showing. The rest are storage plumbing -- schema names,
# internal ids -- and listing them turns a readable row into a debug dump.
INTERESTING_ATTRIBUTES = (
    "server", "host", "domain", "user", "username", "account", "protocol",
    "service", "object", "port", "goa-identity", "application",
)

# How long a copied password stays on the clipboard.
CLIPBOARD_SECONDS = 45


def item_summary(item) -> dict:
    """Everything about a stored secret EXCEPT the secret.

    Nothing in here reads the value. That is not an accident of implementation
    but the contract of this function: enumerating the keyring must never
    require the keyring to hand over what it is protecting.
    """
    attributes = dict(item.get_attributes() or {})
    shown = {key: value for key, value in attributes.items()
             if key in INTERESTING_ATTRIBUTES and value}
    return {
        "path": item.get_object_path(),
        "label": item.get_label() or "Unnamed",
        "schema": attributes.get("xdg:schema", ""),
        "attributes": shown,
        "created": int(item.get_created() or 0),
        "modified": int(item.get_modified() or 0),
        "locked": bool(item.get_locked()),
    }


def items_report(service, Secret) -> dict:
    collections = []
    for collection in service.get_collections():
        entries = collection.get_items() or []
        collections.append({
            "label": collection.get_label() or "Unnamed keyring",
            "path": collection.get_object_path(),
            "locked": bool(collection.get_locked()),
            # A locked collection reports no items rather than an empty one:
            # "nothing stored here" and "cannot look" are different answers.
            "readable": not collection.get_locked(),
            "items": [item_summary(item) for item in entries],
        })
    # Empty, unnamed collections are the session store and similar plumbing.
    collections = [entry for entry in collections
                   if entry["items"] or entry["label"] != "Unnamed keyring"]
    collections.sort(key=lambda entry: (entry["label"] != "Login", entry["label"]))
    return {"collections": collections, "error": ""}


def resolve_item(service, Secret, path: str):
    """An item by its D-Bus path, refusing anything that is not one.

    The caller is a settings page, and a settings page can be wrong or stale --
    an item deleted in another window leaves a path that no longer resolves.
    """
    if not re.fullmatch(r"/org/freedesktop/secrets/collection/[A-Za-z0-9_]+/[0-9]+", path or ""):
        raise ValueError("That is not a stored secret.")
    for collection in service.get_collections():
        for item in collection.get_items() or []:
            if item.get_object_path() == path:
                return item
    raise ValueError("That secret no longer exists.")


def copy_secret(service, Secret, path: str) -> None:
    """Put one stored secret on the clipboard, and nowhere else.

    The value is read in this process and handed to wl-copy on STDIN. It is
    never an argument -- argv is world-readable through /proc, so passing a
    password there would publish it to every process on the machine -- and it
    is never printed, logged, or included in an error message.
    """
    item = resolve_item(service, Secret, path)
    item.load_secret_sync(None)
    value = item.get_secret()
    if value is None:
        raise ValueError("That secret could not be read.")
    secret = value.get_text()
    if secret is None:
        raise ValueError("That secret is not text.")

    encoded = secret.encode("utf-8")
    completed = subprocess.run(["wl-copy"], input=encoded, capture_output=True)
    if completed.returncode != 0:
        # Deliberately does not echo the tool's stderr: a clipboard tool that
        # fails while holding a password should not get to decide what lands in
        # a log.
        raise ValueError("The clipboard is not available.")

    # Clear it again, but only if it is still the thing we put there. The guard
    # compares a HASH, so the reminder process never holds the password -- and
    # a clipboard the user has since replaced is left alone.
    digest = hashlib.sha256(encoded).hexdigest()
    subprocess.Popen(
        ["sh", "-c",
         'sleep "$1"; current="$(wl-paste --no-newline 2>/dev/null | sha256sum | cut -d" " -f1)";'
         ' [ "$current" = "$2" ] && wl-copy --clear',
         "sh", str(CLIPBOARD_SECONDS), digest],
        stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        start_new_session=True,
    )


def forget_secret(service, Secret, path: str) -> None:
    """Delete one stored secret. Irreversible, and the UI confirms first."""
    item = resolve_item(service, Secret, path)
    item.delete_sync(None)


def main():
    action = sys.argv[1] if len(sys.argv) > 1 else "status"
    target = sys.argv[2] if len(sys.argv) > 2 else ""
    if action not in ("status", "unlock", "items", "copy", "forget"):
        print("usage: panama-keyring [status|unlock|items|copy PATH|forget PATH]",
              file=sys.stderr)
        return 2
    if action in ("copy", "forget") and not target:
        print(f"usage: panama-keyring {action} PATH", file=sys.stderr)
        return 2

    try:
        Secret, service = load_service()
    except Exception as error:  # noqa: BLE001 - any failure here is "no keyring"
        # No Secret Service at all is a legitimate state, not a crash: report it
        # so the UI can say so instead of showing an empty card.
        print(json.dumps({
            "available": False, "locked": False, "hasLogin": False,
            "collections": [], "daemon": daemon_origin(),
            "error": f"The secret service is not answering: {error}",
        }))
        return 0

    # These three answer with the item list, so a page never has to ask twice
    # to find out what changed.
    if action in ("items", "copy", "forget"):
        try:
            if action == "copy":
                copy_secret(service, Secret, target)
            elif action == "forget":
                forget_secret(service, Secret, target)
                Secret, service = load_service()
        except Exception as error:  # noqa: BLE001
            # The message is this module's own, never the underlying tool's:
            # an exception raised while a secret is in hand must not get to
            # decide what text reaches a log or a settings page.
            payload = items_report(service, Secret)
            payload["error"] = (str(error) if isinstance(error, ValueError)
                                else "That secret could not be used.")
            print(json.dumps(payload))
            return 0
        print(json.dumps(items_report(service, Secret)))
        return 0

    if action == "unlock":
        login = next(
            (c for c in service.get_collections() if c.get_label() == "Login"), None)
        if login is not None and login.get_locked():
            try:
                # Blocks until the dialog is answered or dismissed.
                service.unlock_sync([login], None)
            except Exception as error:  # noqa: BLE001
                state = report(service, Secret)
                state["error"] = f"The keyring was not unlocked: {error}"
                print(json.dumps(state))
                return 0
            # The collection object caches its state; re-read it.
            Secret, service = load_service()

    print(json.dumps(report(service, Secret)))
    return 0


if __name__ == "__main__":
    sys.exit(main())
