#!/usr/bin/env bash

# Managing a LOCAL user account (see online-accounts for the other kind).
#
# Managing an account must not leak the credential it sets, and must not let
# someone lock themselves out of their own machine.
#
# Nothing here creates, deletes, or modifies a real account. It reads the
# account state, which is safe, and exercises the refusals, which are the part
# that has to hold. Every verb that would change something is read from the
# source instead of being run -- `set-icon gib ""` really does clear the
# avatar, and `delete-user` really does delete, so those are pinned statically
# and never invoked.

set -uo pipefail

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

fail() {
    printf 'user 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-users is not executable'

# The page was rebuilt into several files, so the add-user form and the
# per-user rows may not live in UsersPage.qml any more. Everything structural
# is looked up by what it calls rather than by which file it sits in.
file_calling() {
    grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
}

# ── A new password never reaches a command line ─────────────────────────────
# argv is world-readable through /proc, so a password passed as an argument is
# published to every process on the machine. It is read from stdin, and the
# hashing step reads ITS stdin too, so the cleartext exists only inside these
# two processes.
password_body="$(sed -n '/^def set_password/,/^def /p' "$helper")"
[[ -n "$password_body" ]] || fail 'set_password is missing'
grep -q 'sys.stdin.buffer.read()' <<<"$password_body" \
    || fail 'the new password is not read from stdin'
grep -q 'input=secret' <<<"$password_body" \
    || fail 'the password is not handed to the hashing tool on stdin'
grep -qE '"openssl", "passwd"[^]]*secret' <<<"$password_body" \
    && fail 'the password appears in the hashing command line'
grep -qE '^\s*print\((secret|hashed)' <<<"$password_body" \
    && fail 'the password or its hash is printed'

# The service must not hold one either, beyond the moment it hands it over.
grep -q 'stdinEnabled' "$service" \
    || fail 'the service does not write the password over stdin'
grep -qE 'command:.*set-password.*password' "$service" \
    && fail 'the service puts the password in the command line'
grep -q 'root.pendingPassword = ""' "$service" \
    || fail 'the service never clears the password it was holding'

# ── Resetting a password is not the same as setting one ─────────────────────
#
# "Reset" hands the account back to its owner: accountsservice's
# SetPasswordMode(1) means "choose one at the next sign-in". The whole point is
# that an administrator resetting somebody else's password never learns, types,
# or transports a password -- so this verb must not touch stdin, must not reach
# for a hashing tool, and must not go anywhere near SetPassword.
python3 - "$helper" <<'PY' || fail 'reset-password does not set password mode 1, or it handles password material'
import ast
import sys

tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
               if isinstance(node, ast.FunctionDef) and node.name == "reset_password"), None)
if target is None:
    print("reset-password has no implementation", file=sys.stderr)
    raise SystemExit(1)

# Module constants, so the mode may be a name with a meaning rather than a 1.
constants = {}
for node in tree.body:
    if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant):
        for name in node.targets:
            if isinstance(name, ast.Name):
                constants[name.id] = node.value.value


def literal(node):
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.Name):
        return constants.get(node.id)
    return None


methods = [element.value for node in ast.walk(target)
           for element in ast.walk(node)
           if isinstance(element, ast.Constant) and isinstance(element.value, str)]
if "SetPasswordMode" not in methods:
    print("reset-password does not use accountsservice SetPasswordMode", file=sys.stderr)
    raise SystemExit(1)
for forbidden in ("SetPassword", "SetPasswordHint"):
    if forbidden in methods:
        print(f"reset-password calls {forbidden}, which carries password material",
              file=sys.stderr)
        raise SystemExit(1)

# Mode 1: "no usable password, choose one at the next sign-in". 0 would mean a
# password is set, 2 would mean none is ever needed -- both are somebody else's
# account handed away.
modes = [literal(element) for node in ast.walk(target)
         if isinstance(node, ast.Tuple)
         for element in node.elts]
if 1 not in modes:
    print(f"reset-password does not ask for mode 1: {modes}", file=sys.stderr)
    raise SystemExit(1)

# Nothing that could be a password may pass through it. The docstring is
# excluded on purpose -- it is allowed to say the word.
code = ast.dump(ast.Module(body=[node for node in target.body
                                 if not (isinstance(node, ast.Expr)
                                         and isinstance(node.value, ast.Constant))],
                           type_ignores=[]))
for word in ("stdin", "openssl", "crypt", "passwd"):
    if word in code:
        print(f"reset-password touches {word}; it must only set the mode", file=sys.stderr)
        raise SystemExit(1)
raise SystemExit(0)
PY

grep -q 'function resetPassword' "$service" \
    || fail 'the service cannot reset a password'
grep -qE 'resetPassword[^}]*stdin' "$service" \
    && fail 'the service opens stdin for a reset, which carries nothing to write'

reset_page="$(file_calling 'resetPassword(')"
[[ -n "$reset_page" ]] || fail 'no page offers to reset another account password'
grep -q 'at next sign-in\|at their next sign-in' "$reset_page" \
    || fail 'the reset row does not say the other person sets the new password themselves'

# ── Removing a picture is a real verb, not a deletion of the file ───────────
#
# accountsservice takes an empty IconFile to mean "no avatar" and cleans up
# after itself. Anything else -- unlinking the file the snapshot named, writing
# a blank image -- leaves the database pointing at something that is not there.
icon_body="$(sed -n '/^def set_icon/,/^def [a-z_]*(/p' "$helper")"
[[ -n "$icon_body" ]] || fail 'set_icon is missing'
grep -q 'SetIconFile' <<<"$icon_body" \
    || fail 'the avatar is not written through accountsservice'
python3 - "$helper" <<'PY' || fail 'set-icon with an empty path does not clear the avatar through SetIconFile("")'
import ast
import sys

tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
               if isinstance(node, ast.FunctionDef) and node.name == "set_icon"), None)
if target is None:
    raise SystemExit(1)
dump = ast.dump(target)
# An empty path is a value the function has to recognise, not a path it hands
# to GdkPixbuf -- which would fail, and the avatar would stay.
if 'Constant(value=\'\')' not in dump and 'value=""' not in dump:
    print("set_icon never compares its path against the empty string", file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

grep -q 'function removeIcon' "$service" \
    || fail 'the service cannot remove a picture'
python3 - "$service" <<'PY' || fail 'removeIcon does not send an empty path to the helper'
import re
import sys

text = open(sys.argv[1], encoding="utf-8").read()
start = text.find("function removeIcon")
if start < 0:
    raise SystemExit(1)
body = text[start:text.find("\n    }", start)]
if not re.search(r'"set-icon"[^\]]*""', body):
    print(body.strip()[:300], file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# ── Deleting says what happens to the files, and honors the answer ──────────
#
# The choice is the whole feature: "Keep the files" and "Remove everything" are
# different, irreversible outcomes, and a dropdown whose answer is dropped on
# the way down is worse than no dropdown at all. Each link is pinned
# separately, because any one of them can invert on its own.

# 1. The helper turns its argument into accountsservice's boolean.
delete_body="$(sed -n '/^def delete_user/,/^def /p' "$helper")"
[[ -n "$delete_body" ]] || fail 'delete_user is missing'
grep -q 'You cannot delete the account you are signed in to' <<<"$delete_body" \
    || fail 'the helper would delete the account running it'
grep -q 'only administrator' <<<"$delete_body" \
    || fail 'the helper would remove the last administrator, leaving nobody able to administer the machine'
python3 - "$helper" <<'PY' || fail 'the helper does not derive the DeleteUser flag from its keep/remove argument'
import ast
import sys

tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
               if isinstance(node, ast.FunctionDef) and node.name == "delete_user"), None)
if target is None:
    raise SystemExit(1)
parameters = [argument.arg for argument in target.args.args]
if len(parameters) < 2:
    print("delete_user takes no keep/remove argument at all", file=sys.stderr)
    raise SystemExit(1)
choice = parameters[1]

# The boolean handed to DeleteUser must be computed from that argument. A
# literal True or False there is the bug this whole block exists to catch.
for node in ast.walk(target):
    if not isinstance(node, ast.Call):
        continue
    name = getattr(node.func, "attr", getattr(node.func, "id", ""))
    if name != "Variant":
        continue
    for element in ast.walk(node):
        if isinstance(element, ast.Compare) and any(
                isinstance(sub, ast.Name) and sub.id == choice
                for sub in ast.walk(element)):
            raise SystemExit(0)
print(f"the DeleteUser flag is not computed from {choice}", file=sys.stderr)
raise SystemExit(1)
PY

# 2. The service maps its own parameter the way its name reads. Panama has
#    flipped this polarity once already (removeFiles -> keepFiles); a mapping
#    that says one and sends the other reads correctly in every diff.
python3 - "$service" <<'PY' || fail 'the service maps its keep/remove parameter the wrong way round'
import re
import sys

text = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"function deleteUser\(([^)]*)\)", text)
if match is None:
    print("the service has no deleteUser", file=sys.stderr)
    raise SystemExit(1)
parameters = [part.split(":")[0].strip() for part in match.group(1).split(",")]
if len(parameters) < 2:
    print("deleteUser takes no keep/remove argument", file=sys.stderr)
    raise SystemExit(1)
choice = parameters[1]

body = text[match.end():text.find("\n    }", match.end())]
ternary = re.search(re.escape(choice) + r"\s*\?\s*\"([a-z-]+)\"\s*:\s*\"([a-z-]+)\"", body)
if ternary is None:
    print(f"deleteUser does not pass {choice} through to the helper: {body.strip()[:200]}",
          file=sys.stderr)
    raise SystemExit(1)
when_true, when_false = ternary.groups()
wanted = "keep" if "keep" in choice.lower() else "remove"
if wanted not in when_true or wanted in when_false:
    print(f"{choice} true sends {when_true!r}, false sends {when_false!r}", file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# 3. The page passes the answer, not a constant.
delete_page="$(file_calling 'deleteUser(')"
[[ -n "$delete_page" ]] || fail 'no page deletes an account'
grep -qE 'deleteUser\([^)]*,\s*(true|false)\s*\)' "$delete_page" \
    && fail 'the page hardcodes what happens to the files, so the choice it offers is decorative'
grep -q 'Keep the files' "$delete_page" \
    || fail 'the page never offers to keep the deleted account files'
grep -qE 'Remove everything|Delete the files|Remove the files' "$delete_page" \
    || fail 'the page never offers to remove the deleted account files'

# ── Deleting is confirmed, and says what it destroys ────────────────────────
grep -q 'confirmingRemoval' "$delete_page" \
    || fail 'the page deletes an account without a confirmation step'
grep -q 'This cannot be undone' "$delete_page" \
    || fail 'the page does not say that deleting an account destroys their files'

# The page must not offer to change the type of the only administrator either.
type_page="$(file_calling 'administratorCount')"
[[ -n "$type_page" ]] || fail 'nothing on the page knows how many administrators there are'
grep -q 'administratorCount <= 1' "$type_page" \
    || fail 'the page offers to demote the only administrator'

# And the helper refuses it regardless of what the page offers. Demoting the
# only administrator is the same loss as deleting them -- a machine nobody can
# administer -- and the page is not the only thing that can call this.
python3 - "$helper" <<'PY' || fail 'the helper would demote the only administrator'
import ast
import sys

tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
               if isinstance(node, ast.FunctionDef) and node.name == "set_account_type"), None)
if target is None:
    print("set-account-type has no implementation", file=sys.stderr)
    raise SystemExit(1)
dump = ast.dump(target)
if "administratorCount" not in dump:
    print("set-account-type never counts the administrators", file=sys.stderr)
    raise SystemExit(1)
if "BoundaryError" not in dump:
    print("set-account-type counts them and refuses nothing", file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# ── Managing somebody else ──────────────────────────────────────────────────
#
# Account type, locked accounts and the reset above are the verbs that only
# make sense for an account that is not yours, and each has to exist on both
# sides or the row is a button that does nothing.
grep -q 'function setAccountTypeFor\|function setAccountType' "$service" \
    || fail 'the service cannot change another account type'
grep -q 'function setLocked' "$service" \
    || fail 'the service cannot unlock a locked account'
grep -qE '"set-locked"' "$service" \
    || fail 'the service does not reach the helper set-locked verb'
grep -qE '\bset-locked\b' "$helper" \
    || fail 'the helper has no set-locked verb'
grep -q 'Unlock' "$(file_calling 'setLocked(')" \
    || fail 'a locked account is never offered an unlock'

# ── The add-user form validates while it is being typed ─────────────────────
#
# The regression this pins: the fields were TextFieldRow, which commits on
# blur. Typing a perfectly good user name left the Create button disabled,
# because the property behind the enabled predicate had not been told yet, and
# the form looked broken for as long as the field had focus. So the property
# the predicate reads must be updated on every keystroke, and the field it
# comes from must not be the blur-committing row.
create_page="$(file_calling 'UserAccounts.createUser(')"
[[ -n "$create_page" ]] || fail 'nothing creates a user'

# The blunt half of the same pin, and the one that cannot be argued with: the
# page that carries the add-user form has no blur-committing field anywhere.
# LiveFieldRow reports per keystroke and also on accept, so a row that only
# wants the accept behaviour has no reason to reach for the old one.
grep -nE '^\s*TextFieldRow\s*\{' "$create_page" \
    && fail 'the add-user page still has a blur-committing field, which is the bug'
python3 - "$create_page" "$helper" <<'PY' || fail 'the add-user form does not validate live'
import re
import sys

page = open(sys.argv[1], encoding="utf-8").read()
lines = page.splitlines()

# The rules the form enforces: the one regular expression it tests a user name
# against, wherever it lives -- an `enabled:` predicate, or a property that
# turns the same test into the message under the field. Found by the shape of
# the rule rather than by where it sits, because it has moved once already.
predicate = next((line for line in lines
                  if ".test(" in line and re.search(r"/\^\[a-z", line)), None)
if predicate is None:
    print("the add-user form validates the user name nowhere at all", file=sys.stderr)
    raise SystemExit(1)

held = re.search(r"\.test\(\s*(?:root\.)?([A-Za-z_][\w.]*)", predicate)
if held is None:
    print(f"cannot tell what the form validates: {predicate.strip()}", file=sys.stderr)
    raise SystemExit(1)
name = held.group(1).split(".")[-1]

# They must be the helper's rules. Two expressions that drift apart give a form
# that accepts a name accountsservice then refuses, with no way to tell why.
helper_rule = re.search(r'USERNAME\s*=\s*re\.compile\(r"([^"]+)"\)',
                        open(sys.argv[2], encoding="utf-8").read())
page_rule = re.search(r"/(\^[^/]+\$)/", predicate)
if page_rule is None:
    print(f"the form's user-name test is not a regular expression: {predicate.strip()}",
          file=sys.stderr)
    raise SystemExit(1)
if helper_rule and helper_rule.group(1) != page_rule.group(1):
    print(f"the form validates {page_rule.group(1)}, the helper enforces {helper_rule.group(1)}",
          file=sys.stderr)
    raise SystemExit(1)
# The cap is part of the rules and part of the sentence under the field: a
# 40-character name is accepted by the form and refused by accountsservice.
if "31" not in page_rule.group(1):
    print(f"the form does not cap the user name length: {page_rule.group(1)}", file=sys.stderr)
    raise SystemExit(1)
if "31" not in page:
    print("the form never tells anybody about the length cap", file=sys.stderr)
    raise SystemExit(1)

# Where that property is written, and by what kind of field. `edited` (or a
# text-changed handler) is per-keystroke; `accepted` is blur, which is the bug.
assignments = [index for index, line in enumerate(lines)
               if re.search(rf"\b{re.escape(name)}\s*=", line)
               and "property" not in line]
if not assignments:
    print(f"{name} is never assigned, so the form can never become valid", file=sys.stderr)
    raise SystemExit(1)

LIVE = re.compile(r"on(Edited|TextChanged|TextEdited|DisplayTextChanged)\b")
live = False
for index in assignments:
    window = "\n".join(lines[max(0, index - 4):index + 1])
    if LIVE.search(window):
        live = True
    # The enclosing component: the nearest `Type {` above, at any indentation.
    for above in range(index, -1, -1):
        opener = re.match(r"\s*([A-Z]\w*)\s*\{", lines[above])
        if opener:
            if opener.group(1) == "TextFieldRow":
                print(f"{name} is committed by a TextFieldRow, which only commits on blur "
                      f"(line {above + 1})", file=sys.stderr)
                raise SystemExit(1)
            break

if not live:
    print(f"{name} is only committed on accept, so the form cannot validate while it is typed",
          file=sys.stderr)
    raise SystemExit(1)
raise SystemExit(0)
PY

# ── The snapshot is real, and reports no secrets ────────────────────────────
command -v jq >/dev/null 2>&1 || { printf 'user accounts contract: SKIP (no jq)\n'; exit 0; }
snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
jq -e '.users | type == "array" and length > 0' <<<"$snapshot" >/dev/null \
    || fail 'no accounts were reported'
jq -e '[.users[] | (.userName | length > 0)] | all' <<<"$snapshot" >/dev/null \
    || fail 'an account has no user name'
jq -e '.currentUser | length > 0' <<<"$snapshot" >/dev/null \
    || fail 'the snapshot does not say which account is signed in'

# The page renders these; a snapshot that drops them turns a locked account
# into a normal-looking one nobody can sign in to.
jq -e '[.users[] | has("locked") and has("loginTime")] | all' <<<"$snapshot" >/dev/null \
    || fail 'the snapshot no longer reports whether an account is locked'

offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(password|secret|hash)$";"i"))) | join(", ")' <<<"$snapshot")"
[[ -z "$offenders" ]] || fail "the snapshot carries credential-shaped fields: $offenders"

# System accounts are not people and must not be offered for management.
jq -e '[.users[] | .uid >= 1000] | all' <<<"$snapshot" >/dev/null \
    || fail 'a system account is listed as a manageable user'

# ── The stock avatars are a list, of a shape the gallery can draw ───────────
#
# Read-only: it lists files that ship with the distribution. An empty list is
# a legitimate answer on a machine without the faces package, so the shape is
# what is pinned, not the contents.
stock="$("$helper" stock-avatars 2>/dev/null)" || fail 'stock-avatars failed'
jq -e '.avatars | type == "array"' <<<"$stock" >/dev/null \
    || fail "stock-avatars does not answer with a list: $stock"
jq -e '[.avatars[] | has("name") and has("path")] | all' <<<"$stock" >/dev/null \
    || fail "a stock avatar is missing its name or its path: $stock"
jq -e '[.avatars[] | (.path | startswith("/"))] | all' <<<"$stock" >/dev/null \
    || fail 'a stock avatar path is not absolute, so nothing can load it'
grep -q 'stockAvatars' "$service" \
    || fail 'the service does not offer the stock avatars to the gallery'

# ── Input validation ────────────────────────────────────────────────────────
for bad in "root; rm -rf /" "../escape" "UPPER" ""; do
    result="$("$helper" set-real-name "$bad" "Test" 2>/dev/null | jq -r '.error // ""')"
    [[ -n "$result" ]] || fail "the helper accepted \"$bad\" as a user name"
done

# Deleting refuses before it reaches accountsservice, not after.
[[ -n "$("$helper" delete-user "${USER:-nobody}" 2>/dev/null | jq -r '.error // ""')" ]] \
    || fail 'delete-user with no keep/remove answer was accepted'
[[ -n "$("$helper" delete-user "${USER:-nobody}" sideways 2>/dev/null | jq -r '.error // ""')" ]] \
    || fail 'delete-user accepted an answer that is neither keep nor remove'

# Both vocabularies, on purpose: `keep`/`remove` is what the page says out
# loud, `keep-files`/`remove-files` is what this helper has always taken and
# what anything older still passes. The refusal below is the self-deletion one,
# which is reached only once the keep/remove answer has been understood -- so a
# vocabulary that stopped being recognized would show up here as the wrong
# message rather than as no message.
for vocabulary in keep remove keep-files remove-files; do
    refusal="$("$helper" delete-user "${USER:-nobody}" "$vocabulary" 2>/dev/null \
        | jq -r '.error // ""')"
    [[ -n "$refusal" ]] || fail "the helper agreed to delete the account running it"
    grep -q 'signed in to' <<<"$refusal" \
        || fail "delete-user no longer understands \"$vocabulary\": $refusal"
done

for bad in "root; rm -rf /" "UPPER" ""; do
    [[ -n "$("$helper" reset-password "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \
        || fail "reset-password accepted \"$bad\" as a user name"
done

printf 'user accounts contract: PASS (%d account(s), credentials never on a command line, keep-files honored end to end)\n' \
    "$(jq '.users | length' <<<"$snapshot")"
