512 lines
25 KiB
Bash
Executable File
512 lines
25 KiB
Bash
Executable File
#!/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 consent names the real directory, or none ───────────────────────────
|
|
#
|
|
# Four sentences, the armed confirmation among them, built "/home/" + userName
|
|
# and presented the result as fact. A home directory is not reliably there: it
|
|
# can be moved, or live on another mount. accountsservice reports the real one
|
|
# and the helper already carries it, unread. The one place the app was most
|
|
# confident was the one place it was guessing, and it was asking for consent to
|
|
# an irreversible deletion at the time.
|
|
grep -Fq '"homeDirectory": str(values.get("HomeDirectory") or "")' "$helper" \
|
|
|| fail 'the helper no longer reports the real home directory'
|
|
grep -Fq 'function homeDirectory(user: var): string' "$service" \
|
|
|| fail 'the service does not expose the reported home directory'
|
|
if grep -q '"/home/"' "$delete_page"; then
|
|
fail 'the deletion consent constructs a home path instead of reading the reported one'
|
|
fi
|
|
grep -Fq 'UserAccounts.homeDirectory(' "$delete_page" \
|
|
|| fail 'the deletion consent does not read the reported home directory'
|
|
# And an account with no reported path must be described, not invented.
|
|
grep -Fq 'their home directory' "$delete_page" \
|
|
|| fail 'an account with no reported home directory has nothing honest to say about it'
|
|
|
|
# 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")"
|