240 lines
13 KiB
Bash
Executable File
240 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Printing is driverless only, and the page must not be able to become
|
|
# otherwise by accident.
|
|
#
|
|
# The reason is specific. Choosing a PPD or fetching a vendor driver is most of
|
|
# what the panel this replaces does, and a wrong choice produces a printer that
|
|
# accepts jobs, reports success, and prints nothing -- the worst failure this
|
|
# page could ship, because it looks like it worked. So the helper adds printers
|
|
# that describe their own capabilities over IPP and has no branch that selects
|
|
# anything else.
|
|
#
|
|
# The second rule is the device URI. It is handed to a CUPS backend that runs as
|
|
# root, so it is validated here rather than trusted from a settings page.
|
|
#
|
|
# Read-only: this reads printer state and exercises refusals. It never adds or
|
|
# removes a real printer.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-printers"
|
|
service="$repo_dir/config/dot/quickshell/services/Printers.qml"
|
|
page="$repo_dir/config/dot/quickshell/modules/settings/PrintersPage.qml"
|
|
|
|
fail() {
|
|
printf 'printers contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
for path in "$helper" "$service" "$page"; do
|
|
[[ -r "$path" ]] || fail "missing $path"
|
|
done
|
|
[[ -x "$helper" ]] || fail 'panama-printers is not executable'
|
|
|
|
# ── Driverless only ─────────────────────────────────────────────────────────
|
|
grep -q 'DRIVERLESS_MODEL = "everywhere"' "$helper" \
|
|
|| fail 'the driverless model is not named in one place, so the promise cannot be checked'
|
|
# Exactly one place may set a model, and it must be that constant.
|
|
model_uses="$(grep -c 'ppdname=' "$helper")"
|
|
[[ "$model_uses" == "1" ]] \
|
|
|| fail "ppdname is set in $model_uses places; driverless printing must have exactly one"
|
|
grep -q 'ppdname=DRIVERLESS_MODEL' "$helper" \
|
|
|| fail 'the printer is added with something other than the driverless model'
|
|
# No PPD file handling at all.
|
|
grep -qE '\.ppd|ppd-name|getPPDs|ppdFile|installDriver|foomatic' "$helper" \
|
|
&& fail 'the helper reaches for PPDs or drivers, which this page deliberately does not do'
|
|
# And the page must not offer a driver choice. Comments are stripped first:
|
|
# the page explains the no-driver policy in prose, and an earlier version of
|
|
# this check failed on the explanation rather than on any behaviour.
|
|
page_code="$(grep -vE '^\s*//' "$page")"
|
|
grep -qiE 'select.*driver|choose.*driver|ppdName|driverList' <<<"$page_code" \
|
|
&& fail 'the page offers driver selection'
|
|
# It must say so, rather than leaving someone guessing why their printer is absent.
|
|
grep -qi 'driverless' "$page" \
|
|
|| fail 'the page never explains that only driverless printers are supported'
|
|
|
|
# ── Device URIs are validated, not trusted ──────────────────────────────────
|
|
grep -q 'SAFE_SCHEMES' "$helper" || fail 'device URIs are not restricted by scheme'
|
|
for scheme in file pipe; do
|
|
grep -qE "\"$scheme\"" <<<"$(sed -n '/^SAFE_SCHEMES/,/)/p' "$helper")" \
|
|
&& fail "the $scheme scheme is allowed, and it does not lead to a printer"
|
|
done
|
|
|
|
# ── Printer options are a short closed list, not a passthrough ──────────────
|
|
#
|
|
# `lpadmin -o` is the same door the driver ban closed, reopened from the side.
|
|
# Anything can be set through it -- including ppd-name, and including options
|
|
# that make a printer accept jobs and print nothing -- so the page offers two
|
|
# settings, and the helper knows both of them by name and by value. A caller's
|
|
# string is never spliced into `-o`; it is looked up, and refused when absent.
|
|
grep -qE '^OPTION_[A-Z_]+\s*[:=]' "$helper" \
|
|
|| fail 'the settable options are not declared in one place, so "closed vocabulary" cannot be checked'
|
|
python3 - "$helper" <<'PY' || fail 'the option vocabulary is not the closed media/sides set this page promises'
|
|
import ast
|
|
import sys
|
|
|
|
source = open(sys.argv[1], encoding="utf-8").read()
|
|
vocabulary = {}
|
|
for node in ast.parse(source).body:
|
|
if isinstance(node, ast.AnnAssign):
|
|
name = getattr(node.target, "id", "")
|
|
value = node.value
|
|
elif isinstance(node, ast.Assign):
|
|
name = getattr(node.targets[0], "id", "")
|
|
value = node.value
|
|
else:
|
|
continue
|
|
if not name.startswith("OPTION_") or value is None:
|
|
continue
|
|
try:
|
|
literal = ast.literal_eval(value)
|
|
except ValueError:
|
|
continue
|
|
# A key-to-choices table, not the spelling table beside it: only the dict
|
|
# whose values are collections of choices describes what may be set.
|
|
if isinstance(literal, dict) and literal and all(
|
|
isinstance(choices, (list, tuple, set)) for choices in literal.values()):
|
|
vocabulary.update(literal)
|
|
|
|
if set(vocabulary) != {"media", "sides"}:
|
|
print(f"settable keys are {sorted(vocabulary)}, expected ['media', 'sides']", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
if set(vocabulary["media"]) != {"Letter", "A4", "Legal"}:
|
|
print(f"media values are {sorted(vocabulary['media'])}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
if set(vocabulary["sides"]) != {"one-sided", "two-sided-long-edge", "two-sided-short-edge"}:
|
|
print(f"sides values are {sorted(vocabulary['sides'])}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
raise SystemExit(0)
|
|
PY
|
|
# And set-option must reach for that vocabulary rather than trusting its
|
|
# arguments. The values are validated in one place or they are validated
|
|
# nowhere.
|
|
python3 - "$helper" <<'OPTIONS' || fail 'set-option does not check its key and value against the closed vocabulary'
|
|
import re
|
|
import sys
|
|
|
|
source = open(sys.argv[1], encoding="utf-8").read()
|
|
match = re.search(r"\ndef set_option\b.*?(?=\ndef |\Z)", source, re.S)
|
|
if not match:
|
|
raise SystemExit(1)
|
|
raise SystemExit(0 if re.search(r"OPTION_[A-Z_]+", match.group(0)) else 1)
|
|
OPTIONS
|
|
grep -q 'get-options' "$helper" || fail 'the helper cannot read the options a printer is set to'
|
|
grep -q 'set-option' "$helper" || fail 'the helper cannot set a printer option'
|
|
|
|
# ── A queued job can be held and let go again ───────────────────────────────
|
|
#
|
|
# Cancel was the only verb, so the only way to stop a job going out on the
|
|
# wrong paper was to throw it away and print it again.
|
|
for verb in hold release; do
|
|
grep -q "\"$verb\"" "$helper" || fail "the helper has no $verb verb"
|
|
grep -qi "$verb" <<<"$page_code" || fail "the queue offers no $verb"
|
|
done
|
|
grep -qi 'paper size\|media' <<<"$page_code" \
|
|
|| fail 'the expanded printer offers no paper size'
|
|
grep -qi 'two-sided\|sides' <<<"$page_code" \
|
|
|| fail 'the expanded printer offers no two-sided setting'
|
|
# The dropdowns must show what the printer is set to, not a guess.
|
|
grep -q 'getOptions\|optionsFor\|options(' "$service" \
|
|
|| fail 'the service never reads printer options, so the dropdowns would be showing defaults'
|
|
|
|
# The empty state is one card. It was two rows saying the same thing, which read
|
|
# as two different things you could try.
|
|
[[ "$(grep -c 'Search the network' <<<"$page_code")" -le 1 ]] \
|
|
|| fail 'the "Search the network" row appears more than once'
|
|
|
|
command -v jq >/dev/null 2>&1 || { printf 'printers contract: SKIP (no jq)\n'; exit 0; }
|
|
|
|
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
|
|
|
|
# The REASON matters, not merely that something failed. Without validation these
|
|
# reach CUPS, which refuses them too -- so a test that only checks for "an error"
|
|
# passes with the validation deleted, and proves nothing about this helper.
|
|
for bad in "file:///etc/passwd" "pipe:/bin/sh" "ipp://host; rm -rf /" "/etc/passwd" ""; do
|
|
answer="$(refusal add "$bad" probe)"
|
|
[[ -n "$answer" ]] || fail "the helper accepted \"$bad\" as a printer address"
|
|
[[ "$answer" == "That address cannot be used to reach a printer." ]] \
|
|
|| fail "\"$bad\" was rejected by the printing service rather than by this helper: $answer"
|
|
done
|
|
for bad in "../escape" "has space" "a#b" ""; do
|
|
[[ -n "$(refusal remove "$bad")" ]] \
|
|
|| fail "the helper accepted \"$bad\" as a printer name"
|
|
done
|
|
[[ -n "$(refusal cancel notanumber)" ]] || fail 'the helper accepted a job id that is not a number'
|
|
for verb in hold release; do
|
|
[[ -n "$(refusal "$verb" notanumber)" ]] \
|
|
|| fail "the helper accepted a job id that is not a number for $verb"
|
|
done
|
|
# Options: a key outside the vocabulary, and a value outside its own key's list.
|
|
# Both are refused before anything reaches lpadmin, so running this touches no
|
|
# real printer.
|
|
# The reason again, not merely an error: the printer name is only checked for
|
|
# shape here, so these reach the vocabulary and are refused by it rather than by
|
|
# CUPS -- which means nothing is sent to a real printer either.
|
|
[[ "$(refusal set-option office-laser ppd-name everywhere)" == "That is not a setting this page changes." ]] \
|
|
|| fail 'an option outside the vocabulary was not refused by the vocabulary, which reopens the driver door from the side'
|
|
[[ "$(refusal set-option office-laser media Tabloid)" == "That is not a value this setting accepts." ]] \
|
|
|| fail 'a paper size outside the offered list was not refused by the vocabulary'
|
|
[[ "$(refusal set-option office-laser sides sideways)" == "That is not a value this setting accepts." ]] \
|
|
|| fail 'a two-sided value outside the offered list was not refused by the vocabulary'
|
|
[[ -n "$(refusal set-option '../escape' media Letter)" ]] \
|
|
|| fail 'set-option accepted a bad printer name'
|
|
[[ -n "$(refusal get-options 'has space')" ]] \
|
|
|| fail 'get-options accepted a bad printer name'
|
|
[[ -n "$(refusal bogus-command)" ]] || fail 'an unknown command was accepted'
|
|
|
|
# ── The snapshot describes the machine ──────────────────────────────────────
|
|
snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
|
|
jq -e '(.printers | type == "array") and (.jobs | type == "array") and (.service | type == "object")' \
|
|
<<<"$snapshot" >/dev/null || fail 'the snapshot is missing printers, jobs, or service state'
|
|
jq -e '.service | has("running") and has("startsAtBoot") and has("discoveryAvailable")' \
|
|
<<<"$snapshot" >/dev/null || fail 'the service state is incomplete'
|
|
jq -e '[.printers[] | has("name") and has("state") and has("isDefault")] | all' \
|
|
<<<"$snapshot" >/dev/null || fail 'a printer is missing its name, state, or default flag'
|
|
# At most one default, or the page would show two.
|
|
[[ "$(jq '[.printers[] | select(.isDefault)] | length' <<<"$snapshot")" -le 1 ]] \
|
|
|| fail 'more than one printer is reported as the default'
|
|
|
|
# ── Reading a printer's options is a read ───────────────────────────────────
|
|
# Only the keys the page can write are reported: a dropdown that lists an
|
|
# option nothing can set is a control that does nothing.
|
|
first_printer="$(jq -r '.printers[0].name // ""' <<<"$snapshot")"
|
|
if [[ -n "$first_printer" ]]; then
|
|
options="$("$helper" get-options "$first_printer" 2>/dev/null)" \
|
|
|| fail "get-options failed for $first_printer"
|
|
jq -e '(.options | has("media") and has("sides")) and (.choices | has("media") and has("sides"))' \
|
|
<<<"$options" >/dev/null \
|
|
|| fail "get-options does not report the two settings the page offers, with their choices: $options"
|
|
extra="$(jq -r '[(.options | keys[]), (.choices | keys[])]
|
|
| unique | map(select(. != "media" and . != "sides")) | join(", ")' <<<"$options")"
|
|
[[ -z "$extra" ]] \
|
|
|| fail "get-options offers keys the page cannot set: $extra"
|
|
# Every offered choice has to be one set-option would accept, or the
|
|
# dropdown lists something that is refused the moment it is chosen.
|
|
bad_choice="$(jq -r '
|
|
(.choices.media // []) - ["Letter","A4","Legal"]
|
|
+ ((.choices.sides // []) - ["one-sided","two-sided-long-edge","two-sided-short-edge"])
|
|
| join(", ")' <<<"$options")"
|
|
[[ -z "$bad_choice" ]] \
|
|
|| fail "get-options offers values outside the vocabulary: $bad_choice"
|
|
fi
|
|
|
|
# ── Removal is confirmed ────────────────────────────────────────────────────
|
|
grep -q 'confirmingRemoval' "$page" \
|
|
|| fail 'the page removes a printer without a confirmation step'
|
|
grep -q 'still queued for it is cancelled' "$page" \
|
|
|| fail 'the page does not say that removing a printer cancels its queued jobs'
|
|
|
|
# ── "On demand" is reported as normal, not as a fault ───────────────────────
|
|
# CUPS is socket-activated on this distribution; calling that a problem would
|
|
# send people to fix something that is not broken.
|
|
grep -q 'This is a normal configuration' "$page" \
|
|
|| fail 'a socket-activated printing service is presented as a problem'
|
|
|
|
printf 'printers contract: PASS (%d printer(s), %d job(s), driverless only)\n' \
|
|
"$(jq '.printers | length' <<<"$snapshot")" \
|
|
"$(jq '.jobs | length' <<<"$snapshot")"
|