Skip to content

feat(#122 part 1): uninstaller --purge leaves no trace - #183

Merged
aradanmn merged 2 commits into
mainfrom
fix/122-uninstaller-purge
Aug 1, 2026
Merged

aradanmn merged 2 commits into
mainfrom
fix/122-uninstaller-purge

Conversation

@aradanmn

@aradanmn aradanmn commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Part 1 of #122. Part 2 (routing $HOME artifacts to .workdir/) is #182
independent, no file overlap, either can merge first.

Problem

A full uninstall removed the launcher tree and stopped. Three things always
survived: the BYOK CurseForge key (it lives outside the install root), the
evsieve build container plus its podman storage, and the Steam shortcut — which
the script merely told you to remove yourself.

--purge

Covers all three. Mutually exclusive with --keep-data, checked after the
whole argv is parsed so flag order cannot decide it, and honours --dry-run
throughout. Without --purge nothing changes — a plain full uninstall still
spares the API key on purpose, so a reinstall need not re-prompt.

remove-from-steam.py — the inverse of add-to-steam.py

shortcuts.vdf holds every non-Steam shortcut the user has: emulators,
other launchers, work tools. A byte-splice that guesses wrong doesn't fail
loudly, it silently destroys all of them. So this is a real typed VDF parser,
not a regex, with four guards:

  1. Round-trip check — re-serializing the unmodified parse must reproduce
    the input byte for byte, or we don't understand the file and refuse to write.
  2. Backup — timestamped copy before any rewrite.
  3. Narrow matching — only the launcher path, our exact app name, or an exe
    under TARGET_DIR. A user's own Minecraft shortcut survives.
  4. Steam must not be running — it keeps shortcuts.vdf in memory and
    rewrites it on exit, so an edit made now is silently reverted. We refuse
    (exit 2) rather than report a success that will evaporate. We never restart
    Steam for the user: steam -shutdown over SSH has wedged Game Mode before.

Remaining entries are renumbered contiguously; the SteamGridDB artwork
add-to-steam.py downloaded is removed by appid.

Container removal — a deliberate narrowing of the issue

The issue floated podman system reset. That wipes every container, image
and volume the user owns. On a Deck those are probably only ours, but
"probably" is not a basis for deleting someone else's containers.

So: remove the named mcss-evsieve-build box and its debian:12 image, and
nothing else (PRINCIPLES #7). The shared ~/.local/share/containers store is
reclaimed only when podman reports zero containers and zero images left —
via podman unshare rm, for the mapped-root UIDs a plain rm can't touch.
Otherwise we say we kept it and why.

Say the word if you'd rather have the nuclear option behind an extra flag.

Tests

tests/test_uninstall_purge.sh — 19 tests, in the CI baseline. Weighted toward
what must survive, since that's the unrecoverable direction: foreign
shortcuts and their artwork, worlds under --keep-data, the API key without
--purge, and the whole tree on a rejected or dry-run invocation.

Mutation-tested — nine mutations. Seven caught first pass; two survived
and exposed real gaps
, now closed:

Surviving mutation Gap Fix
dry-run ignored for artwork dry-run was only asserted against the vdf, so a dry run deleting the user's library art would have passed T4.8
round-trip guard removed nothing exercised the guard's refusal, only the property it checks T4.9

Verification

End to end against a sandboxed fake $HOME + Steam tree: our entry and artwork
removed, two foreign shortcuts renumbered 0/1 with artwork intact, backup
written, second run a clean no-op, unparseable file refused and left byte-identical,
and the Steam-running guard exercised against a real process named steam
(killed by tracked PID, never by name).

shellcheck clean, no new warnings. --keep-data regression-tested.

Not done

Not hardware-validated. Needs the destructive ~20 min Deck sitting from the
v1.2.4 plan — the only way to exercise real podman/distrobox removal and a real
Steam library. Worth pairing with the --dry-run output first.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dru6wVc2ZcjiTpa6p7yJDh

@aradanmn

aradanmn commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Heads-up on merge order: #182 and this PR both add a line to the BASELINE map in .github/workflows/ci.yml at the same anchor, so whichever merges second will conflict there — one line, nothing else. Verified by a trial merge; ci.yml is the only conflicting file.

Merge either first and ping me; I'll rebase the other.

Scott and others added 2 commits August 1, 2026 12:10
A full uninstall removed the launcher tree and stopped there, so "get this
Deck back to clean" still meant a hand-written sweep. Three things always
survived: the BYOK CurseForge key outside the install root, the evsieve build
container plus its podman storage, and the Steam shortcut — which the script
merely told you to remove yourself.

--purge covers all three. It is mutually exclusive with --keep-data (checked
AFTER the whole argv is parsed, so flag order cannot decide it) and honours
--dry-run throughout. Without --purge nothing changes: a plain full uninstall
still spares the API key on purpose, so a reinstall need not re-prompt.

remove-from-steam.py — the inverse of add-to-steam.py
-----------------------------------------------------
shortcuts.vdf holds EVERY non-Steam shortcut the user has: emulators, other
launchers, work tools. A byte-splice that guesses wrong does not fail loudly,
it silently destroys all of them. So this is a real typed VDF parser, not a
regex, with four guards:

  1. Re-serializing the unmodified parse must reproduce the input byte for
     byte, or we do not understand the file and refuse to write it.
  2. A timestamped backup is written before any rewrite.
  3. Only provably-ours entries are removed (launcher path, or our exact app
     name, or an exe under TARGET_DIR) — matching is deliberately narrow so a
     user's own Minecraft shortcut survives.
  4. Steam must not be running: it keeps shortcuts.vdf in memory and rewrites
     it on exit, so an edit made now is silently reverted. We refuse (exit 2)
     rather than report a success that will evaporate — and we never restart
     Steam for the user; `steam -shutdown` over SSH has wedged Game Mode.

Remaining entries are renumbered contiguously, and the SteamGridDB artwork
add-to-steam.py downloaded is removed by appid.

Container removal blast radius
------------------------------
The issue floated `podman system reset`. That wipes every container, image and
volume the user owns. On a Deck those are probably only ours, but "probably" is
not a basis for deleting someone else's containers, so this removes the named
mcss-evsieve-build box and its debian:12 image and nothing else (PRINCIPLES
reports zero containers AND zero images left, via `podman unshare rm` for the
mapped-root UIDs a plain rm cannot touch.

Fail-open (PRINCIPLES #5): if remove-from-steam.py cannot be found beside the
uninstaller or fetched, we print the manual instruction the old script always
printed instead of aborting the uninstall.

tests/test_uninstall_purge.sh — 19 tests, in the CI baseline. Weighted toward
what must SURVIVE, since that is the unrecoverable direction: foreign shortcuts
and their artwork, worlds under --keep-data, the API key without --purge, and
the whole tree on a rejected or dry-run invocation.

Mutation-tested (PRINCIPLES #4), nine mutations. Seven were caught first pass;
two survived and exposed real gaps, now closed:
  - dry-run was only asserted against shortcuts.vdf, not the artwork, so a
    dry run that deleted the user's library art would have passed (T4.8);
  - nothing exercised the round-trip guard's refusal, only the property it
    checks (T4.9).

Verified end to end against a sandboxed fake $HOME + Steam tree: our entry and
artwork removed, two foreign shortcuts renumbered 0/1 with artwork intact,
backup written, second run a clean no-op, and the Steam-running guard exercised
against a real process named steam (killed by tracked PID, never by name).

NOT yet hardware-validated — needs the destructive ~20 min Deck sitting in the
v1.2.4 plan, which is the only way to exercise real podman/distrobox removal
and a real Steam library.

Part 1 of #122.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dru6wVc2ZcjiTpa6p7yJDh
Found on the Deck, 2026-08-01, by reading the --purge --dry-run output against
a real install (PRINCIPLES #3 — the reason the rule exists).

The store-reclaim check counts `podman ps -aq` / `podman images -q` to decide
whether ~/.local/share/containers is now empty and safe to remove. In a dry run
nothing has actually been removed, so those counts still included OUR OWN box
and image. On a Deck whose only container is ours the dry run therefore printed

    Kept ... — you have other podman containers/images

while the real run would find 0/0 and delete the store. A dry run that predicts
the opposite of the real outcome is worse than one that predicts nothing: it is
exactly the output an operator reads to decide whether it is safe to proceed.

Discount our own box and image from the counts when DRY_RUN. Also track whether
`podman rmi` actually succeeded — a refused rmi (another container still
references the image) means the image is still there and must count as a
leftover, not as removed.

Tests 19 -> 22, CI baseline bumped. Neither podman nor distrobox exists in CI,
so both are faked on PATH:
  T6.1 dry run predicts REMOVING the store when only ours remain
  T6.2 dry run predicts KEEPING it when a foreign container exists
  T6.3 a populated store survives a REAL purge (the blast-radius rule)

Mutation-verified: reverting to the pre-fix arithmetic turns T6.1 red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dru6wVc2ZcjiTpa6p7yJDh
@aradanmn
aradanmn force-pushed the fix/122-uninstaller-purge branch from cac81e3 to 4bbae5c Compare August 1, 2026 12:11
@aradanmn
aradanmn merged commit d668add into main Aug 1, 2026
4 checks passed
@aradanmn
aradanmn deleted the fix/122-uninstaller-purge branch August 1, 2026 12:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant