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:
Executable
+269
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""What this desktop remembers about what you opened, and how to forget it.
|
||||
|
||||
Two traces, both of them a normal and useful part of a desktop rather than a
|
||||
problem to be alarmed about. This tool measures them and clears them on request.
|
||||
It never clears anything on its own, never runs on a schedule, and reports sizes
|
||||
without ever suggesting that a number is too big -- the software that tells you
|
||||
your machine is dirty is trying to sell you something.
|
||||
|
||||
traces what each trace currently costs, measured now.
|
||||
clear-recents the recent-files list, replaced with an empty but valid
|
||||
document. Not deleted: GTK recreates the file the moment
|
||||
something opens a file anyway, and an empty valid file takes
|
||||
effect in every running application immediately, where a
|
||||
missing one is only noticed on the next write.
|
||||
clear-thumbnails the contents of the thumbnail cache. The folder stays; only
|
||||
what is inside it goes, and nothing is followed out of it.
|
||||
|
||||
Trash is deliberately not here. It is measured and emptied by panama-disks, and
|
||||
one trash implementation is the right number to have.
|
||||
|
||||
Seams, for tests that must not touch a real home directory:
|
||||
|
||||
PANAMA_PRIVACY_RECENTS the recent-files document
|
||||
PANAMA_PRIVACY_THUMBNAILS the thumbnail cache folder
|
||||
|
||||
Both still have to resolve to somewhere inside HOME, so a test points HOME at a
|
||||
scratch directory rather than pointing these at one.
|
||||
|
||||
panama-privacy traces
|
||||
panama-privacy clear-recents
|
||||
panama-privacy clear-thumbnails
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Measuring a thumbnail cache means walking it, and a machine that has browsed a
|
||||
# large picture library has a lot of it. Bounded so the page cannot hang; a walk
|
||||
# that runs out of time reports what it had and says it is a floor.
|
||||
MEASURE_TIMEOUT_SECONDS = 20.0
|
||||
|
||||
# The document GTK keeps the recent-files list in, and the cache every file
|
||||
# manager and image viewer on this desktop shares.
|
||||
RECENTS_RELATIVE = "recently-used.xbel"
|
||||
THUMBNAILS_RELATIVE = "thumbnails"
|
||||
|
||||
# What an emptied recent-files list looks like. Byte for byte the header GTK
|
||||
# writes itself, so the file this leaves behind is one GTK would have written.
|
||||
EMPTY_XBEL = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<xbel version="1.0"\n'
|
||||
' xmlns:bookmark="http://www.freedesktop.org/standards/desktop-bookmarks"\n'
|
||||
' xmlns:mime="http://www.freedesktop.org/standards/shared-mime-info"\n'
|
||||
'>\n'
|
||||
'</xbel>\n'
|
||||
)
|
||||
|
||||
BOOKMARK = re.compile(rb"<bookmark\b")
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or filesystem failure."""
|
||||
|
||||
|
||||
def home() -> Path:
|
||||
return Path(os.path.expanduser("~")).resolve(strict=False)
|
||||
|
||||
|
||||
def data_home() -> Path:
|
||||
configured = os.environ.get("XDG_DATA_HOME") or ""
|
||||
if configured:
|
||||
return Path(configured)
|
||||
return Path(os.path.expanduser("~")) / ".local" / "share"
|
||||
|
||||
|
||||
def cache_home() -> Path:
|
||||
configured = os.environ.get("XDG_CACHE_HOME") or ""
|
||||
if configured:
|
||||
return Path(configured)
|
||||
return Path(os.path.expanduser("~")) / ".cache"
|
||||
|
||||
|
||||
def recents_path() -> Path:
|
||||
override = os.environ.get("PANAMA_PRIVACY_RECENTS") or ""
|
||||
return Path(override) if override else data_home() / RECENTS_RELATIVE
|
||||
|
||||
|
||||
def thumbnails_path() -> Path:
|
||||
override = os.environ.get("PANAMA_PRIVACY_THUMBNAILS") or ""
|
||||
return Path(override) if override else cache_home() / THUMBNAILS_RELATIVE
|
||||
|
||||
|
||||
def confined(target: Path, description: str) -> Path:
|
||||
"""A path this is allowed to write to, or a refusal.
|
||||
|
||||
The same guard panama-disks uses on the cache folder, and for the same
|
||||
reason: this function is the whole reason the buttons on the page are safe
|
||||
to press. A symlink is refused outright rather than followed, and anything
|
||||
that resolves to the home directory itself or to somewhere outside it is
|
||||
refused -- so an XDG variable pointing somewhere alarming, or a cache folder
|
||||
someone linked to /, cannot turn one click into a deleted system.
|
||||
"""
|
||||
if target.is_symlink():
|
||||
raise BoundaryError(f"{description} is a link, so it will not be touched.")
|
||||
resolved = target.resolve(strict=False)
|
||||
root = home()
|
||||
if resolved == root or root not in resolved.parents:
|
||||
raise BoundaryError(f"{description} is not inside your home folder.")
|
||||
return resolved
|
||||
|
||||
|
||||
def measure(path: Path, budget: float) -> tuple[int, float, bool]:
|
||||
"""Bytes used, what is left of the budget, and whether the walk finished."""
|
||||
if not path.exists():
|
||||
return 0, budget, True
|
||||
if budget <= 1.0:
|
||||
return 0, budget, False
|
||||
started = time.monotonic()
|
||||
try:
|
||||
completed = subprocess.run(["du", "-sxb", str(path)], check=False,
|
||||
capture_output=True, text=True, timeout=budget)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
# A walk that ran out of time has consumed the whole budget by
|
||||
# definition; the caller stops rather than starting another.
|
||||
return 0, 0.0, False
|
||||
left = max(budget - (time.monotonic() - started), 0.0)
|
||||
if completed.returncode != 0:
|
||||
return 0, left, False
|
||||
match = re.match(r"^(\d+)", completed.stdout)
|
||||
return (int(match.group(1)) if match else 0), left, match is not None
|
||||
|
||||
|
||||
def recent_entries(path: Path) -> int:
|
||||
"""How many files the recent list remembers.
|
||||
|
||||
Counted by scanning for the opening tag rather than parsing the document:
|
||||
the answer wanted is a count, and an XML parser here would build a tree of
|
||||
every path the user has opened in order to throw it away again.
|
||||
"""
|
||||
try:
|
||||
with path.open("rb") as document:
|
||||
return sum(len(BOOKMARK.findall(chunk))
|
||||
for chunk in iter(lambda: document.read(65536), b""))
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def traces() -> dict:
|
||||
budget = MEASURE_TIMEOUT_SECONDS
|
||||
|
||||
recents = recents_path()
|
||||
recents_present = recents.is_file()
|
||||
recents_bytes = recents.stat().st_size if recents_present else 0
|
||||
|
||||
thumbnails = thumbnails_path()
|
||||
thumbnails_present = thumbnails.is_dir()
|
||||
thumbnails_bytes, budget, complete = measure(thumbnails, budget)
|
||||
|
||||
return {
|
||||
"recents": {
|
||||
"bytes": int(recents_bytes),
|
||||
"entries": recent_entries(recents) if recents_present else 0,
|
||||
"path": str(recents),
|
||||
"present": recents_present,
|
||||
},
|
||||
"thumbnails": {
|
||||
"bytes": int(thumbnails_bytes),
|
||||
"path": str(thumbnails),
|
||||
"present": thumbnails_present,
|
||||
# False when the walk ran out of time, which makes the byte count a
|
||||
# floor rather than an answer. The page says so instead of quoting a
|
||||
# number it cannot stand behind.
|
||||
"measured": complete,
|
||||
},
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def clear_recents() -> None:
|
||||
"""Replace the recent-files list with an empty one.
|
||||
|
||||
Never unlinked. GTK holds the path open and recreates the document on its
|
||||
next write, so deleting it buys nothing an empty document does not, and an
|
||||
empty document is understood by everything reading the list right now.
|
||||
"""
|
||||
target = recents_path()
|
||||
if not target.exists():
|
||||
# Nothing remembered is the state this was asked to produce.
|
||||
return
|
||||
if not target.is_file():
|
||||
raise BoundaryError("The recent-files list is not a file.")
|
||||
resolved = confined(target, "The recent-files list")
|
||||
try:
|
||||
resolved.write_text(EMPTY_XBEL, encoding="utf-8")
|
||||
except OSError as error:
|
||||
raise BoundaryError("The recent-files list could not be emptied.") from error
|
||||
|
||||
|
||||
def clear_thumbnails() -> None:
|
||||
"""Delete what is inside the thumbnail cache, never following a link out.
|
||||
|
||||
A thumbnail an application still has open cannot always be removed, and that
|
||||
is the normal case rather than a failure -- so a partial pass succeeds, and
|
||||
the freshly measured size the caller gets back says how much is left. Only a
|
||||
pass that removed nothing at all is reported as a failure.
|
||||
"""
|
||||
target = thumbnails_path()
|
||||
if not target.exists():
|
||||
return
|
||||
if not target.is_dir():
|
||||
raise BoundaryError("The thumbnail cache is not a folder.")
|
||||
directory = confined(target, "The thumbnail cache")
|
||||
|
||||
removed = 0
|
||||
failures = 0
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
# is_symlink first: a symlinked directory must be unlinked, not
|
||||
# walked, or this deletes whatever it points at.
|
||||
if entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
|
||||
os.unlink(entry.path)
|
||||
else:
|
||||
# rmtree lstats as it goes and refuses to descend a symlink.
|
||||
shutil.rmtree(entry.path, ignore_errors=False)
|
||||
removed += 1
|
||||
except OSError:
|
||||
failures += 1
|
||||
if failures and not removed:
|
||||
raise BoundaryError("The thumbnail cache is in use and nothing could be removed.")
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["traces"]:
|
||||
pass
|
||||
elif arguments == ["clear-recents"]:
|
||||
clear_recents()
|
||||
elif arguments == ["clear-thumbnails"]:
|
||||
clear_thumbnails()
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-privacy traces | clear-recents | clear-thumbnails")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = traces()
|
||||
except OSError:
|
||||
state = {"recents": {"bytes": 0, "entries": 0, "path": "", "present": False},
|
||||
"thumbnails": {"bytes": 0, "path": "", "present": False, "measured": False}}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(traces(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user