355 lines
16 KiB
Bash
Executable File
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'
|