Files
Panama/tests/quickshell/privacy-traces-contract

355 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# Clearing the traces this desktop keeps of you: recent files, thumbnails, and
# the trash.
#
# Privacy used to hand all three to GNOME's panel, which is not running in a
# Hyprland session, so the card that offered them changed nothing. Doing it
# natively means writing to two paths in the user's home, which is where this
# stops being a display concern and starts being a thing that can destroy
# somebody's afternoon. Four rules:
#
# 1. Clearing recent files EMPTIES the file, it does not delete it. GTK
# recreates a missing recently-used.xbel, but not until something writes a
# recent entry -- so a deleted file reads as "cleared" and then repopulates
# from whatever GTK still had in memory. An empty, valid xbel document
# takes effect at once and stays. It also has to remain valid XML: GTK
# given a truncated file writes nothing there again, and file history
# silently stops working.
# 2. Clearing thumbnails stays inside the thumbnail cache. That directory
# collects symlinks, and an rm that follows one deletes whatever it points
# at. The fixture plants exactly that trap.
# 3. The trash has one implementation. Storage already itemizes it, confirms
# it, and empties it; a second one on this page would be a second set of
# guards to keep in step, and the one that falls behind is the one nobody
# is looking at.
# 4. The card does not sell anything. Every "clean my PC" product manufactures
# urgency, and the only difference between this card and those is the copy.
#
# SAFETY: every run of the helper is under `env -i` with HOME, XDG_DATA_HOME and
# XDG_CACHE_HOME inside a scratch tree this file created, and the run that
# deletes anything happens only after the helper has reported the fixture's own
# byte counts back. The real recently-used.xbel and the real thumbnail cache are
# never opened.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_dir="$repo_dir/config/dot/quickshell"
helper="$shell_dir/scripts/panama-privacy"
service="$shell_dir/services/Traces.qml"
page="$shell_dir/modules/settings/PrivacyPage.qml"
fail() {
printf 'privacy traces contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-privacy is not executable'
command -v jq >/dev/null 2>&1 || { printf 'privacy traces contract: SKIP (no jq)\n'; exit 0; }
work="$(mktemp -d /tmp/panama-traces.XXXXXX)"
trap 'rm -rf "$work"' EXIT
# ── The helper can be pointed somewhere else at all ─────────────────────────
#
# Everything below rests on this: a hardcoded /home/… would mean the run that
# deletes is deleting from the real home.
grep -n '"/home/' "$helper" \
&& fail 'the helper hardcodes a path under /home, so it cannot be pointed at a fixture'
for verb in traces clear-recents clear-thumbnails; do
grep -q -- "$verb" "$helper" || fail "the helper has no $verb command"
done
# Trash is deliberately absent from this helper. Rule 3, said where it would be
# broken first. Read past the docstrings, which are allowed to say the word --
# and in fact should, since "the trash is Storage's" is the reason.
python3 - "$helper" <<'PY' || fail 'panama-privacy has grown a trash implementation; Storage already has one'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
# Drop every docstring before looking at what is left.
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
body = getattr(node, "body", [])
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
and isinstance(body[0].value.value, str):
node.body = body[1:]
offenders = sorted({inner.value for inner in ast.walk(tree)
if isinstance(inner, ast.Constant) and isinstance(inner.value, str)
and "trash" in inner.value.lower()})
offenders += sorted({inner.id for inner in ast.walk(tree)
if isinstance(inner, ast.Name) and "trash" in inner.id.lower()})
if offenders:
raise SystemExit(f"the helper's code mentions the trash: {offenders}")
PY
# ── The fixture home ────────────────────────────────────────────────────────
home="$work/home"
data="$home/.local/share"
cache="$home/.cache"
recents="$data/recently-used.xbel"
thumbs="$cache/thumbnails"
mkdir -p "$data" "$thumbs/normal" "$thumbs/large" "$work/outside"
# A recents file with two entries, of a size that could not be confused with a
# real one.
cat >"$recents" <<'XBEL'
<?xml version="1.0" encoding="UTF-8"?>
<xbel version="1.0"
xmlns:bookmark="http://www.freedesktop.org/standards/desktop-bookmarks"
xmlns:mime="http://www.freedesktop.org/standards/shared-mime-info">
<bookmark href="file:///home/fixture/one.txt" added="2026-01-01T00:00:00Z"
modified="2026-01-01T00:00:00Z" visited="2026-01-01T00:00:00Z">
<info><metadata owner="http://freedesktop.org">
<mime:mime-type type="text/plain"/>
</metadata></info>
</bookmark>
<bookmark href="file:///home/fixture/two.txt" added="2026-01-02T00:00:00Z"
modified="2026-01-02T00:00:00Z" visited="2026-01-02T00:00:00Z">
<info><metadata owner="http://freedesktop.org">
<mime:mime-type type="text/plain"/>
</metadata></info>
</bookmark>
</xbel>
XBEL
# 9 thumbnails of 33333 bytes: ~300 KB, a number that appears nowhere else.
for index in $(seq 1 9); do
head -c 33333 /dev/zero >"$thumbs/normal/fixture-$index.png"
done
# The trap. A symlink out of the thumbnail cache to a file that must survive,
# and a symlinked subdirectory, which has to be unlinked rather than descended.
printf 'this file is not a thumbnail and must survive\n' >"$work/outside/precious"
ln -s "$work/outside/precious" "$thumbs/escape-file"
ln -s "$work/outside" "$thumbs/escape-dir"
runh() {
env -i \
PATH="/usr/bin:/bin" \
HOME="$home" \
XDG_DATA_HOME="$data" \
XDG_CACHE_HOME="$cache" \
XDG_CONFIG_HOME="$home/.config" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
# ── The measurement, and the proof it is the fixture's ──────────────────────
state="$(runh traces)" || fail 'traces failed against the fixture'
jq -e '(.recents | has("bytes") and has("entries")) and (.thumbnails | has("bytes"))
and (.error == "" or .error == null)' <<<"$state" >/dev/null \
|| fail "traces is not the shape the card is bound to: $state"
# Nothing that deletes runs until these two hold. 9 x 33333 = 299997 bytes of
# thumbnails, and two recent entries.
jq -e '.thumbnails.bytes > 250000 and .thumbnails.bytes < 400000' <<<"$state" >/dev/null \
|| fail "the thumbnail measurement is not the fixture's ($(jq -r .thumbnails.bytes <<<"$state") bytes); refusing to go on"
jq -e '.recents.entries == 2' <<<"$state" >/dev/null \
|| fail "the recents count is not the fixture's ($(jq -r .recents.entries <<<"$state")); refusing to go on"
jq -e '.recents.bytes > 0' <<<"$state" >/dev/null \
|| fail 'the recents file was measured as empty when it plainly is not'
# ── 1. Clearing recents empties the file, and leaves valid XML ──────────────
before_inode="$(stat -c '%i' "$recents")"
runh clear-recents >/dev/null || fail 'clear-recents failed against the fixture'
[[ -f "$recents" ]] \
|| fail 'clear-recents deleted recently-used.xbel; GTK will not recreate it until something writes a recent entry, so file history stops working until then'
[[ ! -L "$recents" ]] || fail 'recently-used.xbel was replaced by a symlink'
python3 - "$recents" <<'PY' || fail 'clear-recents left something that is not a valid, empty xbel document'
import sys
import xml.etree.ElementTree as ET
path = sys.argv[1]
raw = open(path, "rb").read()
if not raw.strip():
raise SystemExit("the file was truncated to nothing rather than written as an empty xbel; "
"GTK treats a zero-length file as corrupt and stops recording history")
try:
root = ET.fromstring(raw)
except ET.ParseError as error:
raise SystemExit(f"what is left is not parseable XML: {error}")
if root.tag != "xbel":
raise SystemExit(f"the root element is <{root.tag}>, not <xbel>")
bookmarks = root.findall("bookmark")
if bookmarks:
raise SystemExit(f"{len(bookmarks)} bookmark(s) survived clearing")
PY
after="$(runh traces)"
jq -e '.recents.entries == 0' <<<"$after" >/dev/null \
|| fail "recents still reports $(jq -r .recents.entries <<<"$after") entries after clearing"
# Rewritten in place or replaced atomically -- either is fine; unlinking and
# leaving nothing is what rule 1 forbids, and that is already covered above.
# What is checked here is that a second clear on an already-empty file is not
# an error, because the row stays pressable.
runh clear-recents >/dev/null || fail 'clearing an already-empty recents file failed'
[[ -f "$recents" ]] || fail 'the second clear removed the file'
: "$before_inode"
# ── 2. Clearing thumbnails stays inside the thumbnail cache ─────────────────
runh clear-thumbnails >/dev/null || fail 'clear-thumbnails failed against the fixture'
[[ -f "$work/outside/precious" ]] \
|| fail 'clearing thumbnails followed a symlink out of the cache and deleted a file elsewhere'
[[ -d "$work/outside" ]] \
|| fail 'clearing thumbnails deleted a directory outside the cache'
[[ -d "$thumbs" ]] \
|| fail 'the thumbnail directory itself was removed; the thumbnailer expects it to exist'
remaining="$(find "$thumbs" -type f | wc -l)"
[[ "$remaining" == "0" ]] \
|| fail "clearing thumbnails left $remaining file(s) behind, so it did not do what it said"
# The escape hatch itself is gone -- the link is inside the cache, so removing
# it is correct; what must not have happened is following it.
[[ ! -e "$thumbs/escape-file" ]] \
|| fail 'the symlink inside the cache was left behind'
# ── The symlinked cache root, refused rather than followed ──────────────────
#
# The other half of the same trap: not a link inside the cache, but a cache
# that IS a link. Resolving to somewhere outside the home has to be refused
# with a reason rather than emptied.
escaped_home="$work/escaped"
mkdir -p "$escaped_home/.cache" "$work/victim"
printf 'not a thumbnail\n' >"$work/victim/keepme"
ln -s "$work/victim" "$escaped_home/.cache/thumbnails"
escaped_output="$(env -i PATH="/usr/bin:/bin" HOME="$escaped_home" \
XDG_CACHE_HOME="$escaped_home/.cache" XDG_DATA_HOME="$escaped_home/.local/share" \
LANG=C LC_ALL=C "$helper" clear-thumbnails 2>&1)"
escaped_status=$?
[[ -f "$work/victim/keepme" ]] \
|| fail 'a symlinked thumbnail directory was emptied through the link'
[[ -d "$work/victim" ]] \
|| fail 'the directory a symlinked thumbnail cache pointed at was removed'
# Refused in words. Either shape counts -- an error field on the payload the
# card reads, or a non-zero exit -- but silence does not: a row that reports
# success while the cache is untouched is the failure mode.
reason="$(jq -r '.error // ""' <<<"$escaped_output" 2>/dev/null || printf '')"
[[ -n "$reason" || "$escaped_status" -ne 0 ]] \
|| fail 'a thumbnail cache that resolves outside the home was accepted silently'
# ── The seam is not a way around the guard ──────────────────────────────────
#
# A test seam that skips the confinement would make everything above theatre.
# Pointed at a directory outside the home, it has to be refused exactly as a
# symlinked one is.
mkdir -p "$work/elsewhere"
printf 'also not a thumbnail\n' >"$work/elsewhere/keepme"
seam_output="$(env -i PATH="/usr/bin:/bin" HOME="$home" \
XDG_DATA_HOME="$data" XDG_CACHE_HOME="$cache" \
PANAMA_PRIVACY_THUMBNAILS="$work/elsewhere" \
LANG=C LC_ALL=C "$helper" clear-thumbnails 2>&1)"
seam_status=$?
[[ -f "$work/elsewhere/keepme" ]] \
|| fail 'the thumbnail seam pointed outside the home was emptied anyway'
reason="$(jq -r '.error // ""' <<<"$seam_output" 2>/dev/null || printf '')"
[[ -n "$reason" || "$seam_status" -ne 0 ]] \
|| fail 'a thumbnail path outside the home was accepted through the test seam'
# ── 3. One trash implementation ─────────────────────────────────────────────
grep -q 'Disks' "$page" \
|| fail 'the Traces card does not reach the Storage service, so its trash row is a second implementation'
grep -qE 'Disks\.(clean|cleanables)' "$page" \
|| fail 'the page does not empty the trash through the cleanable Storage already has'
# The two ways a second one would appear: the page doing it itself, or the
# Traces service growing the verb.
grep -vE '^\s*//' "$page" | grep -qE 'gio +trash|"trash-empty"|rm -rf.*Trash|\.local/share/Trash' \
&& fail 'the page empties the trash itself rather than through Storage'
grep -vE '^\s*//' "$service" | grep -qiE 'trash' \
&& fail 'the Traces service has grown a trash path; Storage owns that one'
# The row says so, rather than leaving two identical buttons in two places
# looking like two different things.
grep -q 'Trash' "$page" \
|| fail 'the Traces card has no trash row at all'
# ── The seams, so this contract can exist ───────────────────────────────────
#
# Both paths are named seams AND both are re-confined against HOME, which is
# what makes pointing HOME at a scratch tree a real test rather than a way of
# disabling the guard.
for seam in PANAMA_PRIVACY_RECENTS PANAMA_PRIVACY_THUMBNAILS; do
grep -q "$seam" "$helper" \
|| fail "the helper has no $seam seam, so nothing can exercise it without the real home"
done
grep -qE 'PANAMA_PRIVACY|helperPath' "$service" \
|| fail 'the Traces service does not name the helper it runs'
# ── 4. The card does not sell anything ──────────────────────────────────────
python3 - "$page" <<'PY' || fail 'the Traces card uses the language of a cleaner racket'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
text = "\n".join(line for line in source.splitlines() if not line.strip().startswith("//"))
def block_at(start: int) -> str:
depth = 0
for index in range(text.find("{", start), len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
return text[start:index + 1]
return ""
# Found by what it is wired to rather than by its title: the card that reads
# the Traces service is the card under test, whatever it ends up being called.
cards = [block for block in (block_at(match.start())
for match in re.finditer(r"SettingsCard \{", text))
if re.search(r"\bTraces\.", block)]
if not cards:
raise SystemExit("no card on the Privacy page is wired to the Traces service")
card = min(cards, key=len)
# Only what a person reads. QML is full of exclamation marks and none of them
# are shouting at anybody.
copy = " ".join(re.findall(r'"([^"\n]*)"', card)).lower()
PRESSURE = [
"running out", "running low", "act now", "recommended", "we recommend",
"urgent", "boost", "speed up", "optimize", "optimise", "reclaim now",
"free up now", "clean now", "junk", "safe to remove", "you should",
"needs attention", "protect yourself", "at risk", "exposed", "!",
]
found = [phrase for phrase in PRESSURE if phrase in copy]
if found:
raise SystemExit(f"the Traces card says: {found}")
# Nor one button that clears the lot: each trace is a separate thing to lose,
# and losing all three because one of them was worth clearing is not a choice
# anybody made.
if re.search(r'"(Clear (everything|all)|Erase everything|Wipe)"', card):
raise SystemExit("the card offers a single button that clears every trace at once")
PY
printf 'privacy traces contract: PASS (recents emptied as valid xbel, thumbnails confined, trash still Storage\047s)\n'