Give identity its due: native enrollment, honest deletion, and sign-in that stays home
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -6,48 +6,188 @@
|
||||
# nothing, and that silence -- "I enrolled a finger and nothing happened" --
|
||||
# is the failure this card exists to name.
|
||||
#
|
||||
# The helper is the parse surface, so it runs for real against stub fprintd
|
||||
# and authselect; the page and service checks are structural.
|
||||
# Two things changed, and each inverts a pin this file used to hold.
|
||||
#
|
||||
# Enrollment is Panama's now. It used to open GNOME's Users panel, which was
|
||||
# honest while fprintd's guided capture was the only thing there; the helper
|
||||
# drives EnrollStart itself and streams a touch counter, so the handoff is the
|
||||
# thing that would now be wrong. The needle is inverted rather than deleted:
|
||||
# the page must NOT open that panel.
|
||||
#
|
||||
# And the card is no longer hidden by the absence of a reader. `pamEnabled`
|
||||
# used to be reported as false whenever fprintd had nothing to say, so the one
|
||||
# state a person genuinely has to act on -- the PAM feature switched on, no
|
||||
# reader attached, every unlock now waiting on a device that is not there --
|
||||
# rendered as no card at all. The two facts are read independently, and the
|
||||
# card appears when either is true.
|
||||
#
|
||||
# SAFETY. Enrollment claims a real device and authselect rewrites PAM, so
|
||||
# nothing here may reach either. `env -i` with a stub directory first on PATH,
|
||||
# both bus addresses pointed at sockets that do not exist, PANAMA_PATH pointed
|
||||
# at a recording panama-sudo that runs nothing, and the enrollment stream
|
||||
# replayed from PANAMA_FINGERPRINT_FIXTURE -- a canned fprintd whose calls land
|
||||
# in a log file instead of on the bus. The PATH claim is asserted before the
|
||||
# helper is run at all.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint"
|
||||
service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml"
|
||||
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
page="$settings_dir/UsersPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'fingerprint contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Wiring ───────────────────────────────────────────────────────────────────
|
||||
for path in "$helper" "$service" "$page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-fingerprint is not executable'
|
||||
|
||||
rg -Fq 'visible: Fingerprint.readerPresent' "$page" \
|
||||
|| fail 'the card is not hidden on machines with no reader'
|
||||
rg -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \
|
||||
file_calling() {
|
||||
grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
|
||||
}
|
||||
|
||||
# ── Enrollment stays here ────────────────────────────────────────────────────
|
||||
#
|
||||
# The inverted needle. gnome-handoff-contract lost its UsersPage exception at
|
||||
# the same time; this is the other half, said from the page's side.
|
||||
grep -rn --include='*.qml' -E 'openGnomePanel\("(system", *"users|users")' "$settings_dir" \
|
||||
&& fail 'a settings page still sends fingerprint enrollment to GNOME Users, which Panama owns'
|
||||
|
||||
enroll_page="$(file_calling 'Fingerprint.startEnroll(')"
|
||||
[[ -n "$enroll_page" ]] || fail 'nothing starts an enrollment'
|
||||
grep -q 'Fingerprint.cancelEnroll()' "$enroll_page" \
|
||||
|| fail 'an enrollment in progress cannot be cancelled, so the reader stays claimed'
|
||||
grep -qE 'Fingerprint\.(removeFinger|removeAll)\(' "$enroll_page" \
|
||||
|| fail 'an enrolled finger cannot be removed'
|
||||
|
||||
# ── The card is visible in the state that needs acting on ────────────────────
|
||||
#
|
||||
# `readerPresent || unlockFeatureEnabled`. Either alone hides the stuck state:
|
||||
# the feature on with no reader is invisible under the first, and a machine
|
||||
# with a reader and nothing configured is invisible under the second.
|
||||
python3 - "$page" "$service" <<'PY' || fail 'the fingerprint card is not visible in the state that needs acting on'
|
||||
import re
|
||||
import sys
|
||||
|
||||
page = open(sys.argv[1], encoding="utf-8").read()
|
||||
service = open(sys.argv[2], encoding="utf-8").read()
|
||||
|
||||
DISJUNCTION = re.compile(
|
||||
r"readerPresent\s*\|\|\s*(root\.)?unlockFeatureEnabled"
|
||||
r"|unlockFeatureEnabled\s*\|\|\s*(root\.)?readerPresent")
|
||||
|
||||
visible = [line for line in page.splitlines()
|
||||
if "visible:" in line and "Fingerprint." in line]
|
||||
if not visible:
|
||||
print("nothing decides whether the fingerprint card is shown", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
card = visible[0]
|
||||
|
||||
# Either the page spells it out, or it binds to a service property that does.
|
||||
# The second is better -- one expression, so the card and the state it
|
||||
# describes cannot disagree -- so the indirection is followed rather than
|
||||
# forbidden.
|
||||
if DISJUNCTION.search(card):
|
||||
raise SystemExit(0)
|
||||
named = re.search(r"visible:\s*Fingerprint\.(\w+)", card)
|
||||
if named:
|
||||
definition = re.search(rf"property bool {named.group(1)}:([^\n]*)", service)
|
||||
if definition and DISJUNCTION.search(definition.group(1)):
|
||||
raise SystemExit(0)
|
||||
print(f"visible: Fingerprint.{named.group(1)}, which is "
|
||||
f"{definition.group(1).strip() if definition else 'not defined'}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(card.strip(), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# And it says why it is there. A card that appears on a machine with no reader
|
||||
# and offers nothing but a switch reads as a bug in the card.
|
||||
stuck_page="$(file_calling 'Fingerprint.unlockFeatureEnabled')"
|
||||
[[ -n "$stuck_page" ]] || fail 'no page reads the unlock feature independently of the reader'
|
||||
grep -qiE 'no reader' "$stuck_page" \
|
||||
|| fail 'the stuck state does not say there is no reader'
|
||||
grep -qE 'Turn off' "$stuck_page" \
|
||||
|| fail 'the stuck state offers no way out of it'
|
||||
|
||||
grep -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \
|
||||
|| fail 'the unlock switch does not drive authselect'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$page" \
|
||||
|| fail 'enrollment does not hand off to the GNOME Users panel'
|
||||
rg -Fq 'Fingerprint.refresh()' "$page" \
|
||||
grep -Fq 'Fingerprint.refresh()' "$page" \
|
||||
|| fail 'the page never reads the fingerprint state'
|
||||
rg -Fq 'authselect' "$helper" && rg -Fq 'with-fingerprint' "$helper" \
|
||||
|| fail 'the helper does not manage the authselect feature'
|
||||
rg -Fq -- '--reason' "$helper" \
|
||||
|| fail 'the privileged change carries no stated reason'
|
||||
rg -Fq 'function fingerLabel' "$service" \
|
||||
|
||||
# ── A finger is named in exactly one place ───────────────────────────────────
|
||||
#
|
||||
# fprintd's vocabulary is "right-index-finger". What that READS as is a
|
||||
# presentation decision, and two copies of it disagree the first time one is
|
||||
# edited -- so the service presents, the helper only validates, and no page
|
||||
# carries a finger name of its own.
|
||||
grep -q 'function fingerLabel' "$service" \
|
||||
|| fail 'finger names have no single place to be presented from'
|
||||
python3 - "$service" "$settings_dir" "$repo_dir/config/dot/quickshell/services" <<'PY' \
|
||||
|| fail 'fprintd vocabulary is spelled out in more than one place'
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
# ── The helper against stub fprintd and authselect ───────────────────────────
|
||||
FINGER = re.compile(r'"(?:left|right)-(?:thumb|(?:index|middle|ring|little)-finger)"')
|
||||
owner = pathlib.Path(sys.argv[1]).resolve()
|
||||
|
||||
stub_dir="$(mktemp -d)"
|
||||
state_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$stub_dir" "$state_dir"' EXIT
|
||||
# A single id elsewhere is a default choice -- "start on the right index
|
||||
# finger" -- and that is allowed. A second one is a copy of the list, and the
|
||||
# copy is what falls behind: the one in Fingerprint.qml grows a finger, the
|
||||
# other does not, and half the interface offers nine.
|
||||
for directory in sys.argv[2:]:
|
||||
for path in sorted(pathlib.Path(directory).rglob("*.qml")):
|
||||
if path.resolve() == owner:
|
||||
continue
|
||||
found = set(FINGER.findall(path.read_text(encoding="utf-8")))
|
||||
if len(found) > 1:
|
||||
print(f"{path}: {sorted(found)}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# The service carries the enrollment state the panel counts with.
|
||||
for property in unlockFeatureEnabled enrolling enrollStage enrollTotal; do
|
||||
grep -qE "property (bool|int|var|string) $property" "$service" \
|
||||
|| fail "the service does not expose $property, so the page cannot show the enrollment"
|
||||
done
|
||||
|
||||
# ── The privileged change still says why ─────────────────────────────────────
|
||||
grep -Fq 'authselect' "$helper" && grep -Fq 'with-fingerprint' "$helper" \
|
||||
|| fail 'the helper does not manage the authselect feature'
|
||||
grep -Fq -- '--reason' "$helper" \
|
||||
|| fail 'the privileged change carries no stated reason'
|
||||
|
||||
# ── The helper cannot walk past the stubs ────────────────────────────────────
|
||||
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"
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no jq)\n'; exit 0; }
|
||||
command -v python3 >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no python3)\n'; exit 0; }
|
||||
|
||||
# ── The fake machine ─────────────────────────────────────────────────────────
|
||||
|
||||
work="$(mktemp -d /tmp/panama-fingerprint-contract.XXXXXX)"
|
||||
stub_dir="$work/bin"
|
||||
state_dir="$work/state"
|
||||
home_dir="$work/home"
|
||||
run_dir="$work/run"
|
||||
panama_dir="$work/panama/bin"
|
||||
mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$run_dir" "$panama_dir"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
cat >"$stub_dir/fprintd-list" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'fprintd-list %s\n' "\$*" >>"$state_dir/argv"
|
||||
if [[ -e "$state_dir/no-reader" ]]; then
|
||||
echo 'Impossible to enumerate devices: No devices available'
|
||||
echo 'Impossible to enumerate devices: No devices available' >&2
|
||||
exit 1
|
||||
fi
|
||||
cat <<'OUT'
|
||||
@@ -62,49 +202,300 @@ STUB
|
||||
|
||||
cat >"$stub_dir/authselect" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "\$*" >>"$state_dir/authselect-log"
|
||||
printf 'authselect %s\n' "\$*" >>"$state_dir/argv"
|
||||
if [[ "\$1" == "current" ]]; then
|
||||
echo 'Profile ID: local'
|
||||
[[ -e "$state_dir/pam-on" ]] && echo '- with-fingerprint'
|
||||
[[ -e "$state_dir/feature-on" ]] && echo '- with-fingerprint'
|
||||
exit 0
|
||||
fi
|
||||
printf 'fingerprint contract: authselect was asked to change PAM\n' >&2
|
||||
exit 1
|
||||
STUB
|
||||
|
||||
# PANAMA_PATH pointed at an empty directory forces the plain-sudo fallback,
|
||||
# which the stub records instead of escalating.
|
||||
cat >"$stub_dir/sudo" <<STUB
|
||||
# Records the escalation and runs NOTHING. The reason is the point: this page's
|
||||
# prompt has to say what it is about to do, and the only way to see that is to
|
||||
# catch the arguments before they become an authselect invocation.
|
||||
cat >"$panama_dir/panama-sudo" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "\$*" >>"$state_dir/sudo-log"
|
||||
exec "\$@"
|
||||
printf 'panama-sudo %s\n' "\$*" >>"$state_dir/argv"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$stub_dir"/fprintd-list "$stub_dir"/authselect "$stub_dir"/sudo
|
||||
|
||||
run() { PANAMA_PATH="$state_dir" PATH="$stub_dir:$PATH" "$helper" "$@"; }
|
||||
# Anything else that could reach the machine is closed rather than left open.
|
||||
for blocked in sudo pkexec gdbus busctl dbus-send fprintd-enroll fprintd-delete; do
|
||||
cat >"$stub_dir/$blocked" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '$blocked %s\n' "\$*" >>"$state_dir/argv"
|
||||
printf 'fingerprint contract: the helper reached for $blocked\n' >&2
|
||||
exit 1
|
||||
STUB
|
||||
done
|
||||
chmod +x "$stub_dir"/* "$panama_dir/panama-sudo"
|
||||
|
||||
runh() {
|
||||
env -i \
|
||||
PATH="$stub_dir:/usr/bin:/bin" \
|
||||
HOME="$home_dir" \
|
||||
USER=gib \
|
||||
XDG_RUNTIME_DIR="$run_dir" \
|
||||
PANAMA_PATH="$work/panama" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
|
||||
DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
|
||||
LANG=C LC_ALL=C \
|
||||
"$@"
|
||||
}
|
||||
|
||||
run() { runh "$helper" "$@"; }
|
||||
|
||||
# Enrollment and removal replay a canned fprintd; nothing they do reaches a bus.
|
||||
fixture="$work/fprintd.json"
|
||||
log="$work/fprintd.log"
|
||||
runf() {
|
||||
runh env PANAMA_FINGERPRINT_FIXTURE="$fixture" PANAMA_FINGERPRINT_LOG="$log" \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
# The safety claim, verified rather than assumed.
|
||||
for binary in fprintd-list authselect sudo pkexec; 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 against the real one"
|
||||
done
|
||||
|
||||
# ── status: two facts, read independently ────────────────────────────────────
|
||||
|
||||
status="$(run status)"
|
||||
jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \
|
||||
|| fail "the reader name did not parse: $status"
|
||||
jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \
|
||||
|| fail "enrolled fingers did not parse: $status"
|
||||
jq -e '.pamEnabled == false and .error == ""' <<<"$status" >/dev/null \
|
||||
jq -e '.unlockFeatureEnabled == false and .error == ""' <<<"$status" >/dev/null \
|
||||
|| fail "authselect state misread as enabled: $status"
|
||||
# The old name is still answered, because the service and this file both read
|
||||
# it and a silent rename would report "off" forever.
|
||||
jq -e '.pamEnabled == .unlockFeatureEnabled' <<<"$status" >/dev/null \
|
||||
|| fail "the two names for the PAM feature disagree: $status"
|
||||
|
||||
touch "$state_dir/pam-on"
|
||||
jq -e '.pamEnabled == true' <<<"$(run status)" >/dev/null \
|
||||
touch "$state_dir/feature-on"
|
||||
jq -e '.unlockFeatureEnabled == true' <<<"$(run status)" >/dev/null \
|
||||
|| fail 'with-fingerprint enabled was not detected'
|
||||
|
||||
# No reader is a normal machine, not an error.
|
||||
# THE regression. No reader, feature on: the state that used to render as no
|
||||
# card at all, because the feature was only read when fprintd answered.
|
||||
touch "$state_dir/no-reader"
|
||||
jq -e '.reader == false and .error == ""' <<<"$(run status)" >/dev/null \
|
||||
|| fail "a readerless machine was reported as a problem: $(run status)"
|
||||
rm -f "$state_dir/no-reader"
|
||||
stuck="$(run status)"
|
||||
jq -e '.reader == false and .error == ""' <<<"$stuck" >/dev/null \
|
||||
|| fail "a readerless machine was reported as a problem: $stuck"
|
||||
jq -e '.unlockFeatureEnabled == true' <<<"$stuck" >/dev/null \
|
||||
|| fail "the PAM feature was not read on a machine with no reader, which is the one state somebody has to be able to turn off: $stuck"
|
||||
jq -e '.enrolled == []' <<<"$stuck" >/dev/null \
|
||||
|| fail "a machine with no reader reported enrolled fingers: $stuck"
|
||||
rm -f "$state_dir/no-reader" "$state_dir/feature-on"
|
||||
|
||||
# The privileged change goes through, with the right feature name.
|
||||
# ── The privileged change goes through, with the right feature and a reason ──
|
||||
|
||||
: >"$state_dir/argv"
|
||||
run set-unlock on >/dev/null
|
||||
grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/sudo-log" \
|
||||
|| fail 'set-unlock on did not enable the authselect feature'
|
||||
run set-unlock off >/dev/null
|
||||
grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/sudo-log" \
|
||||
|| fail 'set-unlock off did not disable the authselect feature'
|
||||
grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/argv" \
|
||||
|| fail "set-unlock on did not enable the authselect feature: $(cat "$state_dir/argv")"
|
||||
grep -qE 'panama-sudo --reason .*fingerprint' "$state_dir/argv" \
|
||||
|| fail 'the prompt does not say what it is about to do'
|
||||
grep -Eq '^(sudo|pkexec) ' "$state_dir/argv" \
|
||||
&& fail 'the privileged change escalated without a stated reason'
|
||||
|
||||
printf 'fingerprint contract: ok\n'
|
||||
: >"$state_dir/argv"
|
||||
run set-unlock off >/dev/null
|
||||
grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/argv" \
|
||||
|| fail 'set-unlock off did not disable the authselect feature'
|
||||
run set-unlock sideways >/dev/null 2>&1 \
|
||||
&& fail 'set-unlock accepted something that is neither on nor off'
|
||||
|
||||
# ── enroll: a stream, one line per touch ─────────────────────────────────────
|
||||
#
|
||||
# The whole point of doing this natively: the page counts touches while they
|
||||
# happen. So the stream is read as a stream -- every line valid JSON on its own,
|
||||
# the counter monotonic and bounded, and a terminal line that says which way it
|
||||
# ended.
|
||||
|
||||
printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-stage-passed","enroll-retry-scan-too-short","enroll-stage-passed","enroll-stage-passed","enroll-stage-passed","enroll-completed"],"error":""}\n' \
|
||||
>"$fixture"
|
||||
: >"$log"
|
||||
|
||||
runf enroll right-index-finger >"$work/stream.jsonl" \
|
||||
|| fail "a completed enrollment exited nonzero: $(cat "$work/stream.jsonl")"
|
||||
python3 - "$work/stream.jsonl" <<'PY' || fail 'the enrollment stream is not a usable progress stream'
|
||||
import json
|
||||
import sys
|
||||
|
||||
lines = [line for line in open(sys.argv[1], encoding="utf-8").read().splitlines() if line.strip()]
|
||||
if len(lines) < 3:
|
||||
print(f"only {len(lines)} line(s) of progress", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
events = []
|
||||
for line in lines:
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except ValueError:
|
||||
print(f"not a JSON object on its own line: {line}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
for event in events:
|
||||
for field in ("ok", "stage", "done", "total", "result"):
|
||||
if field not in event:
|
||||
print(f"a progress line is missing {field}: {event}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if event["total"] != 5:
|
||||
print(f"the touch count changed mid-enrollment: {event}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if not 0 <= event["done"] <= event["total"]:
|
||||
print(f"the counter left its own range: {event}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
counts = [event["done"] for event in events]
|
||||
if counts != sorted(counts):
|
||||
print(f"the counter went backwards: {counts}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
# A retry is a touch that did NOT count. If it advanced the counter, the panel
|
||||
# would promise a finish that never arrives.
|
||||
retries = [event for event in events if "retry" in str(event["result"])]
|
||||
if not retries:
|
||||
print("the failed touch produced no line at all, so the panel says nothing",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if retries[0]["done"] != 2:
|
||||
print(f"a retried touch was counted as progress: {retries[0]}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
last = events[-1]
|
||||
if not last["ok"] or last["done"] != last["total"]:
|
||||
print(f"a completed enrollment did not finish full: {last}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# The device is claimed and released around it, in that order. A reader left
|
||||
# claimed by a crashed enrollment refuses the next one, and the message a
|
||||
# person gets is "busy with something else" forever.
|
||||
python3 - "$log" <<'PY' || fail 'the reader is not claimed and released around an enrollment'
|
||||
import json
|
||||
import sys
|
||||
|
||||
calls = [json.loads(line)["method"] for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
|
||||
if not calls:
|
||||
print("nothing was called at all", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if calls[0] != "Claim":
|
||||
print(f"the device was used before it was claimed: {calls}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if calls[-1] != "Release":
|
||||
print(f"the device was left claimed: {calls}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if "EnrollStart" not in calls:
|
||||
print(f"nothing started an enrollment: {calls}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if calls.index("EnrollStart") < calls.index("Claim"):
|
||||
print(f"enrollment started before the claim: {calls}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# A finger already on the reader ends the stream badly, and says so in words
|
||||
# rather than in fprintd's vocabulary.
|
||||
printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-duplicate"],"error":""}\n' \
|
||||
>"$fixture"
|
||||
: >"$log"
|
||||
duplicate="$(runf enroll left-thumb)" && fail 'a failed enrollment exited zero'
|
||||
last="$(tail -1 <<<"$duplicate")"
|
||||
jq -e '.ok == false and .stage == "failed"' <<<"$last" >/dev/null \
|
||||
|| fail "a failed enrollment did not end in a failure line: $last"
|
||||
jq -e '(.error | length) > 0 and (.error | test("[a-z] [a-z]"))' <<<"$last" >/dev/null \
|
||||
|| fail "the failure is reported in fprintd's vocabulary rather than in words: $last"
|
||||
grep -Fq '"Release"' "$log" \
|
||||
|| fail 'a failed enrollment left the reader claimed'
|
||||
|
||||
# A refused claim -- the reader busy with the lock screen, most often -- is one
|
||||
# line, not a hang.
|
||||
printf '{"enrollStages":5,"results":[],"error":"The fingerprint reader is busy with something else."}\n' \
|
||||
>"$fixture"
|
||||
: >"$log"
|
||||
refused="$(runf enroll right-thumb)" && fail 'a refused claim exited zero'
|
||||
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refused")" >/dev/null \
|
||||
|| fail "a refused claim did not report a failure: $refused"
|
||||
|
||||
# ── Nonsense is refused before the reader is touched ─────────────────────────
|
||||
printf '{"enrollStages":5,"results":["enroll-completed"],"error":""}\n' >"$fixture"
|
||||
for bad in 'third-eye' 'right-index-finger; reboot' '' 'left-thumb-finger'; do
|
||||
: >"$log"
|
||||
refusal="$(runf enroll "$bad" 2>/dev/null)" \
|
||||
&& fail "enroll accepted ${bad@Q} as a finger"
|
||||
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refusal")" >/dev/null \
|
||||
|| fail "a bad finger name did not come back as a stream failure: $refusal"
|
||||
[[ ! -s "$log" ]] \
|
||||
|| fail "a bad finger name claimed the reader before being refused: ${bad@Q}"
|
||||
: >"$log"
|
||||
[[ -n "$(runf remove "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \
|
||||
|| fail "remove accepted ${bad@Q} as a finger"
|
||||
[[ ! -s "$log" ]] \
|
||||
|| fail "a bad finger name reached the reader through remove: ${bad@Q}"
|
||||
done
|
||||
|
||||
# ── remove: one finger, or all of them, and never somebody else's ────────────
|
||||
|
||||
: >"$log"
|
||||
removed="$(runf remove right-index-finger)" || fail 'remove failed against the canned fprintd'
|
||||
jq -e 'has("reader") and has("enrolled") and has("unlockFeatureEnabled")' <<<"$removed" >/dev/null \
|
||||
|| fail "remove does not answer with the fresh state: $removed"
|
||||
python3 - "$log" right-index-finger <<'PY' || fail 'removing one finger did not delete that one finger'
|
||||
import json
|
||||
import sys
|
||||
|
||||
calls = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
|
||||
methods = [call["method"] for call in calls]
|
||||
if methods[0] != "Claim" or methods[-1] != "Release":
|
||||
print(f"the device was not claimed and released around it: {methods}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
delete = next((call for call in calls if call["method"].startswith("DeleteEnrolledFinger")), None)
|
||||
if delete is None:
|
||||
print(f"nothing was deleted: {methods}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if delete["method"] == "DeleteEnrolledFingers2":
|
||||
print("removing one finger deleted all of them", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if sys.argv[2] not in delete["arguments"]:
|
||||
print(f"a different finger was deleted: {delete}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
: >"$log"
|
||||
runf remove-all >/dev/null || fail 'remove-all failed against the canned fprintd'
|
||||
# DeleteEnrolledFingers2 works on the claimed user. Its predecessor took a user
|
||||
# name, which is how a typo deletes somebody else's prints.
|
||||
grep -Fq '"DeleteEnrolledFingers2"' "$log" \
|
||||
|| fail 'remove-all does not use the method that works on the claimed user'
|
||||
python3 - "$log" <<'PY' || fail 'remove-all names a user, which is how a typo deletes somebody else'
|
||||
import json
|
||||
import sys
|
||||
|
||||
for line in open(sys.argv[1], encoding="utf-8"):
|
||||
if not line.strip():
|
||||
continue
|
||||
call = json.loads(line)
|
||||
if call["method"] == "DeleteEnrolledFingers2" and call["arguments"]:
|
||||
print(call, file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# ── With no fixture and no bus, it still answers in its own shape ────────────
|
||||
#
|
||||
# The page reads this stream line by line. A traceback on stdout, or nothing at
|
||||
# all, leaves an enrollment panel open forever with no way to know it failed.
|
||||
absent="$(run enroll right-index-finger 2>/dev/null)"
|
||||
[[ -n "$absent" ]] || fail 'enrollment against no fprintd printed nothing at all'
|
||||
jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$absent")" >/dev/null \
|
||||
|| fail "enrollment against no fprintd did not answer in the stream's own shape: $absent"
|
||||
|
||||
printf 'fingerprint contract: PASS (status, stuck state, enroll stream, remove, reason)\n'
|
||||
|
||||
Reference in New Issue
Block a user