#!/usr/bin/env bash

# Online accounts -- the other kind (see user-accounts for the local ones).
#
# Three things can go wrong here, and two of them are silent.
#
# The loud one is a leaked password. Adding a Nextcloud or an IMAP account is
# the first time Panama collects a credential for somebody else's server, and
# GOA's AddAccount takes it as a D-Bus argument -- which is fine, that is a
# direct call to a daemon. What is not fine is the same string reaching this
# helper's own argv, where /proc publishes it to every process on the machine,
# or the account listing, which the page renders.
#
# The first silent one is the page telling somebody they have no online
# accounts. `available` used to be `lastError === ""`, and `lastError` is set by
# every failed write -- so refusing to remove one account replaced the whole
# card with "Online accounts are not available on this machine", listing
# nothing, while four accounts sat there working. Availability is whether GOA
# answered the listing. An error is a row.
#
# The second is a removal that happens on the first click. GOA's Remove is
# immediate and unrecoverable: the account is gone, and re-adding it means the
# whole sign-in again. It must take two.
#
# SAFETY. This is the account store of a signed-in desktop session, so it must
# be impossible for anything here to reach the real GOA:
#
#   1. the helper is run under `env -i` with a stub directory first on PATH,
#      and with both D-Bus bus addresses pointed at sockets that do not exist,
#      so a client that got as far as connecting could not;
#   2. `import gi` resolves to a stand-in on PYTHONPATH whose require_version
#      always raises, which is the same seam network-tools-contract uses;
#   3. the account data comes from PANAMA_ACCOUNTS_FIXTURE, a file in the
#      scratch tree, so nothing is read from or written to the session;
#   4. every D-Bus client binary is stubbed with one that records and refuses.
#
# The write verbs are exercised only against that fixture. Nothing here signs
# in to anything, and nothing here removes a real account.

set -uo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-accounts"
service="$repo_dir/config/dot/quickshell/services/OnlineAccounts.qml"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/OnlineAccountsPage.qml"

fail() {
    printf 'online accounts contract: %s\n' "$1" >&2
    exit 1
}

for path in "$helper" "$service" "$page"; do
    [[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-accounts is not executable'

file_calling() {
    grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
}

# Sentinels. Neither may come back out of anything.
readonly NEXTCLOUD_PW='nextcloud-pw-must-never-leave-6d3a'
readonly IMAP_PW='imap-pw-must-never-leave-91be'

# ── Static: the helper cannot walk past the stubs ────────────────────────────
#
# Checked before anything runs, because the safety claim above rests on it.
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")"
[[ -z "$absolute" ]] \
    || fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"

# ── Static: a password is read, never taken as an argument ───────────────────
#
# The distinction that matters: handing the password to GOA over D-Bus is the
# supported way to add a password-based account. Handing it to THIS program as
# argv[4] would publish it through /proc for as long as the command runs.
grep -q 'sys.stdin' "$helper" \
    || fail 'the helper never reads stdin, so the password has no way in but argv'
python3 - "$helper" <<'PY' || fail 'the helper takes a password from its own command line'
import ast
import re
import sys

SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I)
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())

bad = []
for node in ast.walk(tree):
    # password = sys.argv[n], or any assignment of an argv slice to one.
    if isinstance(node, ast.Assign):
        names = [t.id for t in node.targets if isinstance(t, ast.Name)]
        if any(SECRETISH.search(name) for name in names):
            if "argv" in ast.dump(node.value):
                bad.append(f"line {node.lineno}: {names} <- argv")
    # And no command list may carry one either.
    if isinstance(node, (ast.List, ast.Tuple)):
        literals = {e.value for e in node.elts
                    if isinstance(e, ast.Constant) and isinstance(e.value, str)}
        if not literals & {"gdbus", "busctl", "dbus-send", "gnome-control-center", "sh", "bash"}:
            continue
        for element in node.elts:
            name = getattr(element, "id", getattr(element, "attr", ""))
            if name and SECRETISH.search(name):
                bad.append(f"line {node.lineno}: {name} in a command list")
if bad:
    print("; ".join(bad), file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# ── Static: an added account is proved, not assumed ──────────────────────────
#
# GOA's AddAccount does not check what it is handed: it writes the account out
# and stores the password. A mistyped one therefore produces an account that
# exists, looks correct in the list, and never syncs -- which is the exact
# failure this page was built to be able to explain, so it must not be the
# failure the page creates. The credentials are exercised once, and an account
# that cannot sign in is taken back out.
python3 - "$helper" <<'PY' || fail 'a new account is never asked to prove its credentials'
import ast
import sys

tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
source = ast.dump(tree)
if "call_add_account_sync" not in source:
    print("nothing adds an account through GOA at all", file=sys.stderr)
    raise SystemExit(1)
if "call_ensure_credentials_sync" not in source:
    print("an added account is never signed in with, so a mistyped password "
          "produces an account that exists and never syncs", file=sys.stderr)
    raise SystemExit(1)

# And the undo. The function that verifies has to be able to remove.
verify = next((node for node in ast.walk(tree)
               if isinstance(node, ast.FunctionDef) and node.name == "verify"), None)
if verify is None or "call_remove_sync" not in ast.dump(verify):
    print("an account whose credentials were refused is left behind", file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# GOA's provider type for Nextcloud is "owncloud" -- the fork kept the id. A
# provider type GOA does not know is not an error: AddAccount is simply never
# offered it, and the form reports a failure nobody can act on.
grep -qE '"owncloud"' "$helper" \
    || fail 'Nextcloud is added under a provider type GOA does not have (it is "owncloud")'
grep -qE '"imap_smtp"' "$helper" \
    || fail 'mail is added under a provider type GOA does not have (it is "imap_smtp")'

# ── Static: the service hands it down the same way ───────────────────────────
grep -qE 'stdinEnabled' "$service" \
    || fail 'the service never opens a helper stdin, so the password would have to be an argument'
python3 - "$service" <<'PY' || fail 'the service puts an account password on the command line'
import re
import sys

text = open(sys.argv[1], encoding="utf-8").read()
found = False
for match in re.finditer(r"function (addNextcloud|addImap)\(([^)]*)\)", text):
    found = True
    body = text[match.end():text.find("\n    }", match.end())]
    for argv in re.findall(r"\[[^\[\]]*\]", body):
        if re.search(r"(password|secret|token)", argv, re.I):
            print(f"{match.group(1)}: {argv.strip()[:200]}", file=sys.stderr)
            raise SystemExit(1)
if not found:
    print("the service cannot add a password-based account at all", file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# ── Static: availability is not an error ─────────────────────────────────────
#
# The regression, pinned at the definition. `available` may be computed from
# whether the listing was answered; it may not be computed from lastError,
# which every refused write sets.
python3 - "$service" <<'PY' || fail 'the page-wide availability is still derived from a write error'
import re
import sys

text = open(sys.argv[1], encoding="utf-8").read()
if re.search(r"property bool available:", text) is None:
    print("the service no longer says whether online accounts are available", file=sys.stderr)
    raise SystemExit(1)

# Every place `available` is decided: the declaration, and every assignment.
# A write error may not reach any of them. This is checked at each one rather
# than at the declaration alone, because the regression came back the second
# time as an assignment in the error handler of a failed toggle.
WRITE_ERRORS = re.compile(r"(lastError|writeError|setError|removeError)")
sites = [line for line in text.splitlines()
         if re.search(r"property bool available:|\bavailable\s*=[^=]", line)]
if not sites:
    print("nothing sets availability at all", file=sys.stderr)
    raise SystemExit(1)
for line in sites:
    if WRITE_ERRORS.search(line):
        print(line.strip(), file=sys.stderr)
        raise SystemExit(1)

# And the two must be separable at all: one string for "GOA did not answer",
# another for "that one change did not happen".
if len(set(re.findall(r"property string (\w*[Ee]rror)", text))) < 2:
    print("the service keeps one error string, so a failed write is indistinguishable "
          "from a daemon that is not there", file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# ── Static: the page ─────────────────────────────────────────────────────────
#
# An error is a row on a working page, not a replacement for the page.
python3 - "$page" <<'PY' || fail 'the page still hides everything behind an error'
import re
import sys

text = open(sys.argv[1], encoding="utf-8").read()
for line in text.splitlines():
    if re.search(r"visible:.*OnlineAccounts\.lastError\s*===\s*\"\"", line):
        print(line.strip(), file=sys.stderr)
        raise SystemExit(1)
raise SystemExit(0)
PY

remove_page="$(file_calling 'OnlineAccounts.remove(')"
[[ -n "$remove_page" ]] || fail 'nothing removes an online account'
python3 - "$remove_page" <<'PY' || fail 'removing an account is not gated on a confirmation'
import re
import sys

lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
calls = [index for index, line in enumerate(lines) if "OnlineAccounts.remove(" in line]
if not calls:
    raise SystemExit(1)
# The call has to sit behind a state the first click sets: a second button that
# only exists once "confirming" is true. Read from the same object block, so a
# `confirming` property declared elsewhere in the file does not count.
for index in calls:
    window = "\n".join(lines[max(0, index - 25):index + 3])
    if not re.search(r"confirm", window, re.I):
        print(f"line {index + 1}: {lines[index].strip()}", file=sys.stderr)
        raise SystemExit(1)
raise SystemExit(0)
PY

# The account is named in the confirmation, and what is lost is said out loud:
# GOA's Remove cannot be undone and the sign-in has to be done again.
grep -qE 'Sign in again' "$page" \
    || fail 'an account whose credentials expired is not offered a way back in'

# The password field must not survive the form that collected it.
add_page="$(file_calling 'addNextcloud(')"
[[ -n "$add_page" ]] || fail 'nothing adds a Nextcloud account natively'
grep -qE '\.clear\(\)|password = ""|secret = ""' "$add_page" \
    || fail 'the add form never clears the password it collected'


# ── Static: the listing itself cannot carry a credential ─────────────────────
#
# The listing is built from a fixed set of GOA properties, and the page renders
# every one of them. A password property added to that dict later is the way a
# secret would arrive on screen, so the keys are read rather than trusted.
python3 - "$helper" <<'PY' || fail 'the account listing exposes a credential-shaped field'
import ast
import re
import sys

SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I)
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
describe = next((node for node in ast.walk(tree)
                 if isinstance(node, ast.FunctionDef) and node.name == "describe"), None)
if describe is None:
    print("nothing builds the account listing", file=sys.stderr)
    raise SystemExit(1)

keys = []
for node in ast.walk(describe):
    if isinstance(node, ast.Dict):
        keys += [key.value for key in node.keys
                 if isinstance(key, ast.Constant) and isinstance(key.value, str)]
if not keys:
    print("the listing has no fields at all", file=sys.stderr)
    raise SystemExit(1)
offenders = [key for key in keys if SECRETISH.search(key)]
if offenders:
    print(f"the listing carries {offenders}", file=sys.stderr)
    raise SystemExit(1)

# And the GOA properties it reads: `password` is a real property on some
# provider objects, and reading it here would put it in the dict above under
# whatever name somebody chose.
reads = [node.attr for node in ast.walk(describe) if isinstance(node, ast.Attribute)]
offenders = [name for name in reads if SECRETISH.search(name)]
if offenders:
    print(f"the listing reads {offenders} off the GOA account", file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# ── The fake GOA ─────────────────────────────────────────────────────────────

command -v jq >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no python3)\n'; exit 0; }

grep -q 'PANAMA_ACCOUNTS_FIXTURE' "$helper" \
    || fail 'the helper has no fixture seam, so nothing can exercise it without the real account store'

work="$(mktemp -d /tmp/panama-accounts-contract.XXXXXX)"
stub_dir="$work/bin"
home_dir="$work/home"
config_home="$work/config"
state_home="$work/xdg-state"
run_dir="$work/run"
pystub="$work/pystub"
mkdir -p "$stub_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi"
trap 'rm -rf "$work"' EXIT

# The stand-in for PyGObject. With it in place the helper cannot construct a
# Goa.Client at all, so if the fixture seam were ever removed this contract
# would stop working rather than quietly start editing the session's accounts.
cat >"$pystub/gi/__init__.py" <<'GISTUB'
"""Stand-in for PyGObject, so panama-accounts cannot reach the real GOA."""


def require_version(namespace, version):
    raise ValueError(f"Namespace {namespace} not available")
GISTUB

# Two accounts, one of them needing attention. The fixture is the listing's own
# shape, so it holds no password -- that half is pinned above, from the source.
fixture="$work/accounts.json"
log="$work/accounts.log"
cat >"$fixture" <<'FIXTURE'
{
  "accounts": [
    {
      "path": "/org/gnome/OnlineAccounts/Accounts/account_0",
      "provider": "owncloud",
      "providerName": "Nextcloud",
      "providerIcons": ["goa-account-owncloud"],
      "identity": "gib@cloud.example.org",
      "needsAttention": false,
      "services": [
        { "key": "files", "label": "Files", "enabled": true },
        { "key": "calendar", "label": "Calendar", "enabled": false }
      ]
    },
    {
      "path": "/org/gnome/OnlineAccounts/Accounts/account_1",
      "provider": "imap_smtp",
      "providerName": "Mail",
      "providerIcons": ["goa-account-mail"],
      "identity": "gib@example.com",
      "needsAttention": true,
      "services": [
        { "key": "mail", "label": "Mail", "enabled": true }
      ]
    }
  ],
  "error": ""
}
FIXTURE

# Anything that could still be a way out is closed rather than left open.
for blocked in gdbus busctl dbus-send gnome-control-center goa-daemon pkexec; do
    cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf 'online accounts contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/*

runh() {
    env -i \
        PATH="$stub_dir:/usr/bin:/bin" \
        PYTHONPATH="$pystub" \
        HOME="$home_dir" \
        XDG_CONFIG_HOME="$config_home" \
        XDG_STATE_HOME="$state_home" \
        XDG_RUNTIME_DIR="$run_dir" \
        DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
        DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
        PANAMA_ACCOUNTS_FIXTURE="$fixture" \
        PANAMA_ACCOUNTS_LOG="$log" \
        LANG=C LC_ALL=C \
        "$helper" "$@"
}

# The safety claim, verified rather than assumed.
for binary in gdbus busctl dbus-send gnome-control-center; do
    resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)"
    [[ "$resolved" == "$stub_dir/$binary" ]] \
        || fail "$binary resolves to '$resolved', not the stub; refusing to run"
done

leak_in_scratch() {
    grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" "$log" \
        2>/dev/null | head -1
}
recorded() { cat "$log" 2>/dev/null; }
error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; }

# ── The listing answers, under both of its names ─────────────────────────────
#
# The service asks for `snapshot`, which is what every other Panama helper
# calls this; `list` is what this one was called first and what anything older
# still passes. Both have to work, or one of them is a page with no accounts.
: >"$log"
accounts="$(runh snapshot 2>/dev/null)" || fail 'snapshot failed against the fixture'
[[ "$(runh list 2>/dev/null)" == "$accounts" ]] \
    || fail 'list and snapshot do not answer the same thing'
jq -e '.accounts | type == "array" and length == 2' <<<"$accounts" >/dev/null \
    || fail "the fixture accounts did not come back: $accounts"
jq -e '[.accounts[] | has("path") and has("provider") and has("identity")
        and has("needsAttention") and (.services | type == "array")] | all' \
    <<<"$accounts" >/dev/null || fail "an account is missing part of its shape: $accounts"
jq -e '[.accounts[] | select(.needsAttention)] | length == 1' <<<"$accounts" >/dev/null \
    || fail 'the account whose credentials expired is not reported as needing attention'
jq -e '.error == ""' <<<"$accounts" >/dev/null \
    || fail "reading the accounts reported an error: $accounts"
[[ -z "$(recorded)" ]] || fail 'reading the account list changed something'

# ── Adding: the password arrives on stdin and stays nowhere ──────────────────
#
# The sentinel is typed in, and then looked for everywhere it could have gone:
# back out of the helper, into the log, into the scratch home. The log records
# its LENGTH, which is what proves it was read at all rather than dropped.

: >"$log"
added="$(printf '%s\n' "$NEXTCLOUD_PW" \
    | runh add-nextcloud 'https://cloud.example.org' 'gib' 2>"$work/nextcloud.err")"
jq -e '.error == ""' <<<"$added" >/dev/null \
    || fail "a well-formed Nextcloud account was refused: $added"
jq -e '.accounts | length == 2' <<<"$added" >/dev/null \
    || fail "add-nextcloud does not answer with the account list: $added"
jq -e --argjson want "${#NEXTCLOUD_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \
    || fail "the password never reached the helper on stdin: $(recorded)"

grep -Fq "$NEXTCLOUD_PW" <<<"$added" \
    && fail 'add-nextcloud echoes the password back in its own output'
grep -Fq "$NEXTCLOUD_PW" "$work/nextcloud.err" \
    && fail 'add-nextcloud wrote the password to stderr'
leaked="$(leak_in_scratch "$NEXTCLOUD_PW")"
[[ -z "$leaked" ]] || fail "the Nextcloud password was written to $leaked"

: >"$log"
mail_added="$(printf '%s\n' "$IMAP_PW" \
    | runh add-imap 'gib@example.com' 'imap.example.com' 'smtp.example.com' 'gib' \
      2>"$work/imap.err")"
jq -e '.error == ""' <<<"$mail_added" >/dev/null \
    || fail "a well-formed mail account was refused: $mail_added"
jq -e --argjson want "${#IMAP_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \
    || fail "the mail password never reached the helper on stdin: $(recorded)"
grep -Fq "$IMAP_PW" <<<"$mail_added" && fail 'add-imap echoes the password back'
grep -Fq "$IMAP_PW" "$work/imap.err" && fail 'add-imap wrote the password to stderr'
leaked="$(leak_in_scratch "$IMAP_PW")"
[[ -z "$leaked" ]] || fail "the mail password was written to $leaked"

# ── An error is a row, not an empty page ─────────────────────────────────────
#
# The regression, from the data. Whatever went wrong, the accounts that are
# there must still come back with it -- the page draws its list from the same
# answer that carries the message.
: >"$log"
refused="$(printf 'anything\n' | runh add-nextcloud '' 'gib' 2>/dev/null)"
jq -e '(.error | length) > 0' <<<"$refused" >/dev/null \
    || fail "adding an account with no server was accepted: $refused"
jq -e '.accounts | length == 2' <<<"$refused" >/dev/null \
    || fail "a refused add emptied the account list, which is how one error hides four working accounts: $refused"

# ── Adding validates what it was given ───────────────────────────────────────
#
# Each of these produces an account that exists, looks right, and never syncs
# -- the failure this page is meant to be able to explain. They must be refused
# here, with nothing recorded, rather than handed to GOA to fail slowly.
#
# A password is piped in on purpose: without one the refusal would be "no
# password was provided", which every case would pass on regardless of whether
# the field it is about is checked at all.
attempt() { printf 'unused-password\n' | runh "$@" 2>/dev/null | jq -r '.error // ""'; }

for bad_server in '' 'not a url' 'ftp://cloud.example.org' 'https://' \
                  'https://cloud.example.org; reboot' 'https://cloud example org'; do
    : >"$log"
    [[ -n "$(attempt add-nextcloud "$bad_server" 'gib')" ]] \
        || fail "add-nextcloud accepted ${bad_server@Q} as a server"
    [[ -z "$(recorded)" ]] \
        || fail "a refused Nextcloud server was acted on anyway: ${bad_server@Q}"
done
[[ -n "$(attempt add-nextcloud 'https://cloud.example.org' '')" ]] \
    || fail 'add-nextcloud accepted an empty user name'
[[ -n "$(error_of add-nextcloud 'https://cloud.example.org' 'gib' </dev/null)" ]] \
    || fail 'add-nextcloud with no password at all was accepted'

for bad_address in '' 'not-an-address' 'gib@' '@example.com'; do
    [[ -n "$(attempt add-imap "$bad_address" 'imap.example.com' 'smtp.example.com' 'gib')" ]] \
        || fail "add-imap accepted ${bad_address@Q} as an address"
done
for bad_host in '' 'imap example com' 'imap.example.com; reboot' '-imap.example.com'; do
    : >"$log"
    [[ -n "$(attempt add-imap 'gib@example.com' "$bad_host" 'smtp.example.com' 'gib')" ]] \
        || fail "add-imap accepted ${bad_host@Q} as an incoming server"
    [[ -n "$(attempt add-imap 'gib@example.com' 'imap.example.com' "$bad_host" 'gib')" ]] \
        || fail "add-imap accepted ${bad_host@Q} as an outgoing server"
    [[ -z "$(recorded)" ]] \
        || fail "a refused mail server was acted on anyway: ${bad_host@Q}"
done

[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted'

# ── Removing names the account it was asked about ────────────────────────────
#
# Confirmed on the page (pinned above); here, only that the path travels intact
# -- removing the wrong account is unrecoverable and looks like a success.
: >"$log"
runh remove '/org/gnome/OnlineAccounts/Accounts/account_1' >/dev/null 2>&1
jq -e '.arguments[0] == "/org/gnome/OnlineAccounts/Accounts/account_1"' \
    <<<"$(recorded)" >/dev/null || fail "remove did not carry the account path: $(recorded)"

: >"$log"
runh set '/org/gnome/OnlineAccounts/Accounts/account_0' calendar true >/dev/null 2>&1
jq -e '.arguments == ["/org/gnome/OnlineAccounts/Accounts/account_0", "calendar", "true"]' \
    <<<"$(recorded)" >/dev/null || fail "a service toggle did not carry its arguments: $(recorded)"

# ── Nothing anywhere left a secret behind ────────────────────────────────────
for secret in "$NEXTCLOUD_PW" "$IMAP_PW"; do
    leaked="$(leak_in_scratch "$secret")"
    [[ -z "$leaked" ]] || fail "a password was left behind in $leaked"
done

printf 'online accounts contract: PASS (listing, availability, add, remove, no secret leaves)\n'
