Show every answer the portal remembers, and give SSH keys their missing half
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -8,19 +8,29 @@
|
||||
# /dev/video0 directly, and a settings page implying otherwise is worse
|
||||
# than one that says nothing -- so the limit is stated on the page, not
|
||||
# buried in a comment.
|
||||
# 2. A device nothing has asked for is reported empty, not omitted. "No
|
||||
# 2. A table nothing has asked for is reported empty, not omitted. "No
|
||||
# application uses your microphone" and a page that quietly leaves the
|
||||
# microphone out look identical and mean very different things.
|
||||
# 3. Absence is not failure. The store answers "No entry for microphone" for a
|
||||
# device nobody has requested; treating that as an error would make the
|
||||
# table nobody has requested; treating that as an error would make the
|
||||
# whole page fail because one device is unused.
|
||||
# 4. Anything that is not an explicit "yes" is withheld. Guessing generously
|
||||
# about a camera is the wrong way to be wrong.
|
||||
# 5. A refusal states its reason.
|
||||
# 6. THE NEW ONE. screencast and remote-desktop do not hold yes/no. Their
|
||||
# permissions are structured GVariants -- which monitors, whether the
|
||||
# pointer is included, how long the grant lasts -- and there is no honest
|
||||
# way to reconstruct one from a switch. So those two tables are
|
||||
# revoke-only: DeletePermission exists for them and SetPermission does not,
|
||||
# anywhere, because a toggle that writes a plausible-looking variant would
|
||||
# silently rewrite a grant the user never described.
|
||||
#
|
||||
# The write path is exercised against an application id that does not exist, so
|
||||
# no real application's camera access is changed. What is on this machine --
|
||||
# OBS Studio and GNOME Snapshot -- is read, never written.
|
||||
# SAFETY: this file makes NO live portal writes. The earlier version set and
|
||||
# cleared a camera permission on the real permission store under a probe
|
||||
# application id and put it back afterwards; the write path is now exercised
|
||||
# against a recording `busctl` stub under `env -i`, with the session bus
|
||||
# address pointed at a socket that does not exist. Nothing here can reach the
|
||||
# store this desktop is actually using.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
@@ -28,8 +38,7 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-permissions"
|
||||
service="$repo_dir/config/dot/quickshell/services/Permissions.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/PrivacyPage.qml"
|
||||
|
||||
probe="org.panama.ContractProbe"
|
||||
qml_dir="$repo_dir/config/dot/quickshell"
|
||||
|
||||
fail() {
|
||||
printf 'permissions contract: %s\n' "$1" >&2
|
||||
@@ -40,75 +49,416 @@ for path in "$helper" "$service" "$page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-permissions is not executable'
|
||||
command -v jq >/dev/null 2>&1 || { printf 'permissions contract: SKIP (no jq)\n'; exit 0; }
|
||||
|
||||
field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; }
|
||||
|
||||
state="$("$helper" snapshot)" || fail 'snapshot failed'
|
||||
|
||||
if [[ "$(printf '%s' "$state" | field "['available']")" != "True" ]]; then
|
||||
printf 'permissions contract: skipped (the portal permission store is not running)\n'
|
||||
exit 0
|
||||
fi
|
||||
work="$(mktemp -d /tmp/panama-permissions.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# ── 1. The page states the limit ────────────────────────────────────────────
|
||||
|
||||
grep -q 'directly' "$page" \
|
||||
|| fail 'the page does not say that programs outside the portal reach these devices anyway'
|
||||
|
||||
# ── 2 & 3. Unused devices are present and empty, not an error ───────────────
|
||||
# ── 6a. No Set path for the structured tables, by AST ────────────────────────
|
||||
#
|
||||
# Read from the syntax tree rather than by grepping for the word, because the
|
||||
# thing being pinned is reachability: which table names can arrive at the one
|
||||
# call that writes. A second settable list, or a settable list that quietly
|
||||
# grew "screencast", is exactly the change this has to catch.
|
||||
|
||||
printf '%s' "$state" | python3 -c "
|
||||
import json, sys
|
||||
state = json.load(sys.stdin)
|
||||
if state['error']:
|
||||
raise SystemExit(f\"snapshot reported an error: {state['error']}\")
|
||||
names = [d['id'] for d in state['devices']]
|
||||
for required in ('camera', 'microphone', 'speakers'):
|
||||
if required not in names:
|
||||
raise SystemExit(f'{required} is missing from the snapshot entirely')
|
||||
" || fail 'a device with no recorded application was dropped or reported as an error'
|
||||
python3 - "$helper" <<'PY' || fail 'the helper can write a permission for a table whose values it cannot construct'
|
||||
import ast
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
# ── 4 & 5. The write path, on an application that does not exist ────────────
|
||||
path = sys.argv[1]
|
||||
source = open(path, encoding="utf-8").read()
|
||||
tree = ast.parse(source)
|
||||
|
||||
before="$(printf '%s' "$state" | field "['devices']")"
|
||||
# Imported rather than pattern-matched, so the lists are read at their real
|
||||
# values. They are derived from a table registry rather than typed out, and a
|
||||
# contract that only understood one spelling of that would be pinning the
|
||||
# spelling. Importing is safe: the module does its work under __main__.
|
||||
loader = importlib.machinery.SourceFileLoader("panama_permissions", path)
|
||||
module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader))
|
||||
# Registered before it runs: a dataclass declared inside it looks its own module
|
||||
# up by name while the decorator is running.
|
||||
sys.modules[loader.name] = module
|
||||
loader.exec_module(module)
|
||||
|
||||
denied="$("$helper" set camera "$probe" deny)" || fail 'set deny failed'
|
||||
reason="$(printf '%s' "$denied" | field "['error']")"
|
||||
[[ -z "$reason" ]] || fail "denying refused a valid write: $reason"
|
||||
printf '%s' "$denied" | python3 -c "
|
||||
import json, sys
|
||||
for device in json.load(sys.stdin)['devices']:
|
||||
for app in device['applications']:
|
||||
if app['app'] == '$probe':
|
||||
if app['allowed']:
|
||||
raise SystemExit('a denied application was reported as allowed')
|
||||
raise SystemExit(0)
|
||||
raise SystemExit('the denied application was not written at all')
|
||||
" || fail 'deny did not take effect -- the write path is not doing anything'
|
||||
structured = {"screencast", "remote-desktop"}
|
||||
|
||||
allowed="$("$helper" set camera "$probe" allow)" || fail 'set allow failed'
|
||||
printf '%s' "$allowed" | python3 -c "
|
||||
import json, sys
|
||||
for device in json.load(sys.stdin)['devices']:
|
||||
for app in device['applications']:
|
||||
if app['app'] == '$probe' and app['allowed']:
|
||||
raise SystemExit(0)
|
||||
raise SystemExit('allow did not take effect')
|
||||
" || fail 'allow did not take effect'
|
||||
|
||||
# Refusals name their reason rather than merely failing.
|
||||
reason="$(printf '%s' "$("$helper" set camera "$probe" maybe)" | field "['error']")"
|
||||
[[ "$reason" == *"allow or deny"* ]] \
|
||||
|| fail "an invalid decision was not refused with a reason (got: $reason)"
|
||||
def as_names(value):
|
||||
if isinstance(value, dict):
|
||||
value = list(value)
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
names = [item for item in value if isinstance(item, str)]
|
||||
return set(names) if len(names) == len(list(value)) else set()
|
||||
return set()
|
||||
|
||||
reason="$(printf '%s' "$("$helper" set nonsense "$probe" allow)" | field "['error']")"
|
||||
[[ -n "$reason" ]] || fail 'an unknown device was accepted'
|
||||
|
||||
# ── Put it back ────────────────────────────────────────────────────────────
|
||||
collections = {name: as_names(getattr(module, name))
|
||||
for name in dir(module) if not name.startswith("_")}
|
||||
collections = {name: value for name, value in collections.items() if value}
|
||||
|
||||
"$helper" forget camera "$probe" >/dev/null || fail 'forget failed'
|
||||
after="$("$helper" snapshot | field "['devices']")"
|
||||
[[ "$before" == "$after" ]] \
|
||||
|| fail 'the contract changed recorded permissions and did not restore them'
|
||||
known = {name for name, value in collections.items() if structured <= value}
|
||||
if not known:
|
||||
raise SystemExit("nothing in the helper names screencast and remote-desktop as tables, "
|
||||
"so it does not know they exist")
|
||||
|
||||
printf 'permissions contract: ok\n'
|
||||
# Exactly one place writes. More than one is two policies, and the second one
|
||||
# is the one that falls behind.
|
||||
writers = []
|
||||
deleters = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
literals = [inner.value for inner in ast.walk(node)
|
||||
if isinstance(inner, ast.Constant) and isinstance(inner.value, str)]
|
||||
if "SetPermission" in literals:
|
||||
writers.append(node)
|
||||
if "DeletePermission" in literals:
|
||||
deleters.append(node)
|
||||
|
||||
if len(writers) != 1:
|
||||
raise SystemExit(f"expected exactly one function calling SetPermission, found "
|
||||
f"{[node.name for node in writers]}")
|
||||
if not deleters:
|
||||
raise SystemExit("nothing calls DeletePermission, so a grant can never be revoked")
|
||||
|
||||
# One call site, not merely one function. A second Set inside the same function,
|
||||
# reached by a different branch, would satisfy the count above and defeat the
|
||||
# membership check below it.
|
||||
call_sites = sum(1 for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Constant) and node.value == "SetPermission")
|
||||
if call_sites != 1:
|
||||
raise SystemExit(f"SetPermission is named {call_sites} times; there is one honest "
|
||||
"place to write a permission and it is not two")
|
||||
|
||||
writer = writers[0]
|
||||
writer_names = {inner.id for inner in ast.walk(writer) if isinstance(inner, ast.Name)}
|
||||
|
||||
# The gate: a named list of table names, consulted by the one function that
|
||||
# writes. A chain of conditions would do the same job today and grow an
|
||||
# exception later; a list membership is one place to look.
|
||||
gate = {name for name in writer_names & set(collections) if "camera" in collections[name]}
|
||||
if not gate:
|
||||
raise SystemExit(f"{writer.name} writes without consulting any list of the tables that "
|
||||
"MAY be written, so refusing screencast is a runtime accident rather "
|
||||
"than a rule")
|
||||
|
||||
# The way this breaks is not by deleting the list; it is by somebody adding
|
||||
# "screencast" to it because the page wanted a switch there.
|
||||
for name in gate:
|
||||
if structured & collections[name]:
|
||||
raise SystemExit(f"{name}, which {writer.name} consults before writing, lists "
|
||||
f"{sorted(structured & collections[name])} alongside the tables "
|
||||
"that carry a plain yes/no")
|
||||
|
||||
writer_literals = {inner.value for inner in ast.walk(writer)
|
||||
if isinstance(inner, ast.Constant) and isinstance(inner.value, str)}
|
||||
leaked = structured & writer_literals
|
||||
if leaked:
|
||||
raise SystemExit(f"{writer.name} mentions {sorted(leaked)} by name, next to the one call "
|
||||
"that writes a permission")
|
||||
PY
|
||||
|
||||
# ── 6b. Nothing in the shell asks to set a structured table ─────────────────
|
||||
#
|
||||
# The rule said from the QML side: no page and no service may hand a
|
||||
# revoke-only table to the write path, whatever the helper would do with it.
|
||||
offenders="$(grep -rn --include='*.qml' -E 'setPermission\([^)]*(screencast|remote-desktop)' "$qml_dir" || true)"
|
||||
[[ -z "$offenders" ]] \
|
||||
|| fail "something offers to set a permission it cannot construct: $offenders"
|
||||
|
||||
grep -q 'revoke' "$service" \
|
||||
|| fail 'the Permissions service has no revoke path, so a structured grant is permanent'
|
||||
grep -qE 'revokeOnlyTables|revokeOnly' "$service" \
|
||||
|| fail 'the service does not name which tables are revoke-only, so the page has to guess'
|
||||
grep -qE 'simpleTables|simpleValued' "$service" \
|
||||
|| fail 'the service does not name which tables carry a plain yes/no'
|
||||
grep -q 'tables' "$service" \
|
||||
|| fail 'the service does not expose the per-table model the page is built from'
|
||||
|
||||
# The Applications page reads the camera/microphone half of this service in the
|
||||
# older `devices` shape. Generalizing the model to six tables kept that view
|
||||
# rather than rewriting a second page's bindings, and something has to notice if
|
||||
# it disappears -- the symptom there is an empty list, not an error.
|
||||
grep -q 'property var devices' "$service" \
|
||||
|| fail 'the devices view is gone; ApplicationsPage reads it and would quietly show nothing'
|
||||
consumers="$(grep -rln --include='*.qml' 'Permissions\.devices' "$qml_dir" || true)"
|
||||
[[ -n "$consumers" ]] \
|
||||
|| fail 'nothing reads Permissions.devices any more, so the compatibility view is dead weight'
|
||||
|
||||
# ── The hermetic bus ────────────────────────────────────────────────────────
|
||||
#
|
||||
# A recording `busctl` first on PATH, answering from a fixture. The proof that
|
||||
# it is the one being called is the fixture's own application ids coming back
|
||||
# out of `snapshot` -- asserted before anything that writes runs.
|
||||
|
||||
mkdir -p "$work/bin"
|
||||
export PANAMA_PERMISSIONS_CALL_LOG="$work/calls"
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
|
||||
cat >"$work/bin/busctl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
# A permission store that exists only inside this contract.
|
||||
#
|
||||
# The method and its arguments are read positionally rather than by pattern, so
|
||||
# `List s screencast` and `Lookup ss screencast <token>` answer with the shapes
|
||||
# the real store answers with -- an array of ids for one, a dictionary of
|
||||
# applications for the other. Answering both with the same shape would let a
|
||||
# helper that never lists a table pass anyway.
|
||||
printf '%s\n' "$*" >>"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
|
||||
method=""
|
||||
rest=()
|
||||
collecting=0
|
||||
for argument in "$@"; do
|
||||
if (( collecting )); then
|
||||
rest+=("$argument")
|
||||
continue
|
||||
fi
|
||||
case "$argument" in
|
||||
List|Lookup|SetPermission|DeletePermission|GetPermission)
|
||||
method="$argument"
|
||||
collecting=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$method" ]]; then
|
||||
# `busctl --user list`, which is how the helper asks whether the store is
|
||||
# running at all.
|
||||
printf 'org.freedesktop.impl.portal.PermissionStore 1234 - - - -\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
table="${rest[1]:-}"
|
||||
entry="${rest[2]:-}"
|
||||
|
||||
ids() { printf '{"type":"as","data":[[%s]]}\n' "$1"; }
|
||||
row() { printf '{"type":"a{sas}v","data":[%s,{"type":"s","data":""}]}\n' "$1"; }
|
||||
absent() { printf 'No entry for %s\n' "$1" >&2; exit 1; }
|
||||
|
||||
case "$method" in
|
||||
SetPermission|DeletePermission)
|
||||
printf '{"type":"","data":[]}\n'
|
||||
exit 0 ;;
|
||||
List)
|
||||
case "$table" in
|
||||
# Two remembered sessions for one application, which is the normal
|
||||
# case: the page asks one question and the store holds four answers.
|
||||
# The third id is deliberately not an id -- a store that handed back
|
||||
# something path-shaped must not have it fed straight back in.
|
||||
screencast) ids '"session-a","session-b","../../etc/passwd"' ;;
|
||||
remote-desktop) ids '"session-r"' ;;
|
||||
background) ids '"background"' ;;
|
||||
location) ids '' ;;
|
||||
*) ids '' ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
Lookup)
|
||||
case "$table/$entry" in
|
||||
devices/camera)
|
||||
row '{"org.panama.FixtureCam":["yes"],"org.panama.FixtureBlocked":["no"]}' ;;
|
||||
devices/microphone)
|
||||
# Nothing has ever asked. The store says so by having no row.
|
||||
absent microphone ;;
|
||||
screencast/session-a|screencast/session-b)
|
||||
row '{"org.panama.FixtureCast":["1","screen","0"]}' ;;
|
||||
remote-desktop/session-r)
|
||||
row '{"org.panama.FixtureRemote":["1","keyboard,pointer","0"]}' ;;
|
||||
background/background)
|
||||
row '{"org.panama.FixtureBackground":["yes"],"org.panama.FixtureQuiet":["no"]}' ;;
|
||||
*)
|
||||
absent "${entry:-$table}" ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
esac
|
||||
printf '{"type":"","data":[]}\n'
|
||||
STUB
|
||||
chmod +x "$work/bin/busctl"
|
||||
|
||||
runh() {
|
||||
env -i \
|
||||
PATH="$work/bin:/usr/bin:/bin" \
|
||||
HOME="$work/home" \
|
||||
XDG_RUNTIME_DIR="$work/run" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=$work/no-such-bus" \
|
||||
PANAMA_PERMISSIONS_CALL_LOG="$PANAMA_PERMISSIONS_CALL_LOG" \
|
||||
LANG=C LC_ALL=C \
|
||||
"$helper" "$@"
|
||||
}
|
||||
mkdir -p "$work/home" "$work/run"
|
||||
|
||||
# The helper has to find busctl on PATH for the stub to mean anything.
|
||||
grep -qE '/usr/bin/busctl|/bin/busctl' "$helper" \
|
||||
&& fail 'the helper names an absolute busctl, so it cannot be pointed at a fixture'
|
||||
|
||||
resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" command -v busctl)"
|
||||
[[ "$resolved" == "$work/bin/busctl" ]] \
|
||||
|| fail "busctl resolves to $resolved, not the stub; refusing to run anything that writes"
|
||||
|
||||
calls() { cat "$PANAMA_PERMISSIONS_CALL_LOG"; }
|
||||
wrote() { grep -c 'SetPermission' "$PANAMA_PERMISSIONS_CALL_LOG"; }
|
||||
deleted() { grep -c 'DeletePermission' "$PANAMA_PERMISSIONS_CALL_LOG"; }
|
||||
|
||||
state="$(runh snapshot)" || fail 'snapshot failed against the fixture bus'
|
||||
|
||||
# ── 2, 3 & 4. The shape, and the fixture proving this ran against the stub ──
|
||||
|
||||
jq -e '.available == true and (.tables | type == "object") and .error == ""' <<<"$state" >/dev/null \
|
||||
|| fail "snapshot is not the tables shape the page is built from: $state"
|
||||
|
||||
for table in camera microphone screencast remote-desktop background; do
|
||||
jq -e --arg t "$table" '.tables | has($t)' <<<"$state" >/dev/null \
|
||||
|| fail "the $table table is missing from the snapshot entirely"
|
||||
jq -e --arg t "$table" '[.tables[$t][] | has("app") and has("allowed")] | all' <<<"$state" >/dev/null \
|
||||
|| fail "a $table row is not { app, allowed }"
|
||||
# `grants` is how a folded row says how many stored answers are behind it,
|
||||
# and `raw` is the store's own value -- the page explains a structured grant
|
||||
# rather than reducing it to a switch, and cannot do that from a boolean.
|
||||
jq -e --arg t "$table" '[.tables[$t][] | has("grants") and has("raw")] | all' <<<"$state" >/dev/null \
|
||||
|| fail "a $table row carries no grant count, so a folded row cannot say what it folded"
|
||||
done
|
||||
|
||||
# The tables the model dropped. "speakers" was in the devices list because the
|
||||
# portal has an entry for it, not because anything ever asks: no portal backend
|
||||
# arbitrates speaker access, so the row was a permanent "nothing has asked" for
|
||||
# a question nobody is asking. Listing it made the page look like it covered
|
||||
# more than it does.
|
||||
jq -e '.tables | has("speakers") | not' <<<"$state" >/dev/null \
|
||||
|| fail 'speakers is back in the permissions model, where nothing ever asks'
|
||||
|
||||
# The proof of the fixture: these ids exist nowhere but in this file.
|
||||
jq -e '[.tables.camera[] | select(.app == "org.panama.FixtureCam" and .allowed == true)] | length == 1' \
|
||||
<<<"$state" >/dev/null \
|
||||
|| fail "snapshot did not read the fixture's camera table; refusing to go on: $state"
|
||||
jq -e '[.tables.camera[] | select(.app == "org.panama.FixtureBlocked" and .allowed == false)] | length == 1' \
|
||||
<<<"$state" >/dev/null \
|
||||
|| fail 'a permission that is not an explicit yes was reported as allowed'
|
||||
|
||||
# Rule 3, from the store's own words: "No entry for microphone" is a table
|
||||
# nobody has asked about, not a failure of the page.
|
||||
jq -e '.tables.microphone == []' <<<"$state" >/dev/null \
|
||||
|| fail "an unused table was not reported as empty: $(jq -c .tables.microphone <<<"$state")"
|
||||
jq -e '.error == ""' <<<"$state" >/dev/null \
|
||||
|| fail 'an unused table was reported as an error, which fails the whole page over one unused device'
|
||||
|
||||
jq -e '[.tables["remote-desktop"][] | select(.app == "org.panama.FixtureRemote")] | length == 1' \
|
||||
<<<"$state" >/dev/null \
|
||||
|| fail 'a structured grant was dropped rather than listed for revoking'
|
||||
|
||||
# One application, two remembered screencast sessions, ONE row. The page asks
|
||||
# "may this application share your screen", which is one question; a store that
|
||||
# holds four answers to it must not become four rows.
|
||||
jq -e '[.tables.screencast[] | select(.app == "org.panama.FixtureCast")] | length == 1' \
|
||||
<<<"$state" >/dev/null \
|
||||
|| fail "one application's several remembered sessions became several rows: $(jq -c .tables.screencast <<<"$state")"
|
||||
|
||||
# An id the store handed back that is not an id shape is dropped, not fed
|
||||
# straight back into a Lookup.
|
||||
grep -qF '../../etc/passwd' "$PANAMA_PERMISSIONS_CALL_LOG" \
|
||||
&& fail 'a path-shaped entry id from the store was passed back into the permission store'
|
||||
|
||||
# The two lists the page builds itself from, and they do not overlap.
|
||||
jq -e '(.simpleTables | index("screencast")) == null
|
||||
and (.simpleTables | index("remote-desktop")) == null
|
||||
and (.revokeOnlyTables | index("screencast")) != null
|
||||
and (.revokeOnlyTables | index("remote-desktop")) != null' <<<"$state" >/dev/null \
|
||||
|| fail "the page is told the wrong tables are toggleable: $(jq -c '{simpleTables,revokeOnlyTables}' <<<"$state")"
|
||||
|
||||
# ── 6c. The write path, at runtime ──────────────────────────────────────────
|
||||
#
|
||||
# Which vocabulary the helper takes is read from the helper rather than
|
||||
# assumed, so this pins the rule and not the spelling.
|
||||
|
||||
probe="org.panama.ContractProbe"
|
||||
verb=""
|
||||
for candidate in true allow; do
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
if [[ "$(runh set camera "$probe" "$candidate" | jq -r '.error')" == "" ]]; then
|
||||
verb="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -n "$verb" ]] || fail 'the helper accepted neither `set camera APP true` nor `set camera APP allow`'
|
||||
(( "$(wrote)" >= 1 )) \
|
||||
|| fail "setting a simple table never reached SetPermission: $(calls)"
|
||||
|
||||
# Background is the other toggleable one, and the one people actually come to
|
||||
# this page for.
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set background "$probe" "$verb" | jq -r '.error')"
|
||||
[[ -z "$reason" ]] || fail "the background table refused a plain yes/no write: $reason"
|
||||
(( "$(wrote)" >= 1 )) || fail "setting background never reached SetPermission: $(calls)"
|
||||
|
||||
# The applications the fixture store actually holds a grant for -- forgetting
|
||||
# something nobody granted is a different case, checked below.
|
||||
declare -A GRANT_HOLDER=(
|
||||
[screencast]=org.panama.FixtureCast
|
||||
["remote-desktop"]=org.panama.FixtureRemote
|
||||
)
|
||||
|
||||
for table in screencast remote-desktop; do
|
||||
holder="${GRANT_HOLDER[$table]}"
|
||||
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set "$table" "$holder" "$verb" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] \
|
||||
|| fail "setting $table was accepted, but its value cannot be honestly constructed"
|
||||
(( "$(wrote)" == 0 )) \
|
||||
|| fail "setting $table was refused in words but still wrote to the store: $(calls)"
|
||||
|
||||
# Revoking, which is the one thing these tables do support.
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh forget "$table" "$holder" | jq -r '.error')"
|
||||
[[ -z "$reason" ]] || fail "revoking a $table grant was refused: $reason"
|
||||
(( "$(deleted)" >= 1 )) \
|
||||
|| fail "revoking $table never reached DeletePermission: $(calls)"
|
||||
done
|
||||
|
||||
# Every remembered session, not the first one. An application with two stored
|
||||
# screencast grants whose row still says "allowed" after Revoke is the failure
|
||||
# this catches.
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
runh forget screencast org.panama.FixtureCast >/dev/null
|
||||
(( "$(deleted)" == 2 )) \
|
||||
|| fail "revoking dropped $(deleted) of the fixture's two remembered screencast sessions"
|
||||
|
||||
# Forgetting something nobody granted says so rather than reporting success.
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh forget screencast "$probe" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail 'revoking a grant that does not exist reported success'
|
||||
(( "$(deleted)" == 0 )) || fail 'revoking a grant that does not exist still deleted something'
|
||||
|
||||
# ── 5. Refusals name their reason, and reach nothing ────────────────────────
|
||||
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set camera "$probe" maybe | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail 'an invalid decision was accepted'
|
||||
(( "$(wrote)" == 0 )) || fail 'an invalid decision still wrote to the store'
|
||||
|
||||
for bad_table in nonsense ../devices 'devices camera' ''; do
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set "$bad_table" "$probe" "$verb" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail "the table id '$bad_table' was accepted"
|
||||
(( "$(wrote)" == 0 )) || fail "the table id '$bad_table' reached the store"
|
||||
reason="$(runh forget "$bad_table" "$probe" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail "forgetting the table id '$bad_table' was accepted"
|
||||
done
|
||||
|
||||
for bad_app in '../../etc/passwd' 'not an app' '' '-'; do
|
||||
: >"$PANAMA_PERMISSIONS_CALL_LOG"
|
||||
reason="$(runh set camera "$bad_app" "$verb" | jq -r '.error')"
|
||||
[[ -n "$reason" ]] || fail "the application id '$bad_app' was accepted"
|
||||
(( "$(wrote)" == 0 )) || fail "the application id '$bad_app' reached the store"
|
||||
done
|
||||
|
||||
# A refusal answers in the page's own shape rather than collapsing: the page
|
||||
# reads .tables off every reply, including the ones that say no.
|
||||
jq -e '.tables | type == "object"' <<<"$(runh set nonsense "$probe" "$verb")" >/dev/null \
|
||||
|| fail 'a refusal came back without the tables the page is bound to'
|
||||
|
||||
printf 'permissions contract: PASS (tables read from a fixture bus, no live portal write)\n'
|
||||
|
||||
Reference in New Issue
Block a user