Files
voculinux/tests/test_uninstall_cwd_safety.py
T
soubarnak ea7be5f111 feat(identity): rename user-visible identity to voculinux with one-time legacy import
The fork takes its own name everywhere a user can see it: binaries
(voculinux, voculinux-gui), desktop entry, autostart, tray, D-Bus
namespace (com.voculinux.Voculinux, no alias for the old name),
config/data/cache dirs, single-instance lock, IBus engine identity,
tray icons, resource prefixes, VOCULINUX_* environment names, and the
update-checker user agent. The internal Python module stays vocalinux
and the distribution metadata keeps the upstream URLs as provenance.

On first run a previous Vocalinux install's config.json, custom
dictionary, and portal restore token are copied once into the new
locations (new config always wins, no merge, no write-back); models
re-resolve through the pinned-checksum download path. An offline
naming-sweep test proves no old-name strings remain on user-visible
surfaces outside the allowlist.

Refs #18
2026-11-06 01:29:39 +05:30

426 lines
16 KiB
Python

"""uninstall.sh must not delete the caller's working directory.
The website runs ``bash /tmp/vul.sh`` from whatever directory the terminal is
in. ``venv/``, ``build/``, ``dist/``, and Python bytecode are removed only when
the script itself lives in a Voculinux source checkout, and only inside that
checkout.
"""
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
UNINSTALL_SH = REPO_ROOT / "uninstall.sh"
def _write_script(dest: Path) -> None:
dest.write_bytes(UNINSTALL_SH.read_bytes())
dest.chmod(0o755)
def _decoy_tree(root: Path) -> None:
"""Files a careless ``find .`` / ``rm -rf venv`` would destroy."""
package = root / "proj" / "pkg"
cache = package / "__pycache__"
cache.mkdir(parents=True)
(package / "mod.py").write_text("x = 1\n", encoding="utf-8")
(cache / "mod.cpython-312.pyc").write_bytes(b"pyc")
(root / "bytecode-only.pyc").write_bytes(b"pyc")
(root / "bytecode-only.pyo").write_bytes(b"pyo")
(root / "venv" / "bin").mkdir(parents=True)
(root / "venv" / "bin" / "python").write_text("#!/bin/sh\n", encoding="utf-8")
(root / "build").mkdir()
(root / "build" / "out").write_text("build", encoding="utf-8")
(root / "dist").mkdir()
(root / "dist" / "pkg.whl").write_text("wheel", encoding="utf-8")
(root / "notes.txt").write_text("keep", encoding="utf-8")
(root / ".pytest_cache").mkdir()
(root / ".coverage").write_text("cov", encoding="utf-8")
(root / "src" / "pkg.egg-info").mkdir(parents=True)
(root / "activate-voculinux.sh").write_text("echo hi\n", encoding="utf-8")
(root / "voculinux-run.py").write_text("print(1)\n", encoding="utf-8")
def _assert_decoy_intact(root: Path) -> None:
assert (root / "notes.txt").read_text(encoding="utf-8") == "keep"
assert (root / "proj" / "pkg" / "mod.py").is_file()
assert (root / "proj" / "pkg" / "__pycache__" / "mod.cpython-312.pyc").is_file()
assert (root / "bytecode-only.pyc").is_file()
assert (root / "bytecode-only.pyo").is_file()
assert (root / "venv" / "bin" / "python").is_file()
assert (root / "build" / "out").is_file()
assert (root / "dist" / "pkg.whl").is_file()
assert (root / ".pytest_cache").is_dir()
assert (root / ".coverage").is_file()
assert (root / "src" / "pkg.egg-info").is_dir()
assert (root / "activate-voculinux.sh").is_file()
assert (root / "voculinux-run.py").is_file()
def _make_checkout(root: Path) -> Path:
checkout = root / "voculinux"
(checkout / "src" / "vocalinux").mkdir(parents=True)
(checkout / "pyproject.toml").write_text(
'[project]\nname = "voculinux"\n',
encoding="utf-8",
)
(checkout / "src" / "vocalinux" / "__init__.py").write_text("", encoding="utf-8")
_write_script(checkout / "uninstall.sh")
return checkout
def _write_stub(path: Path, body: str) -> None:
path.write_text(body, encoding="utf-8")
path.chmod(0o755)
def _run(script: Path, cwd: Path, home: Path, *args: str) -> subprocess.CompletedProcess[str]:
"""Run uninstall.sh against a fake home, without the host's pgrep or kill.
The uninstaller looks up this uid's processes and can TERM then KILL a
running Voculinux. A stub earlier on PATH records the call and matches
nothing, so the suite cannot stop the app a developer has open.
"""
bin_dir = home.parent / "uninstall-test-bin"
bin_dir.mkdir(exist_ok=True)
pgrep_log = home.parent / "pgrep.log"
kill_log = home.parent / "kill.log"
_write_stub(
bin_dir / "pgrep",
"#!/bin/sh\n" 'printf \'%s\\n\' "$*" >> "$VOCULINUX_TEST_PGREP_LOG"\n' "exit 1\n",
)
_write_stub(
bin_dir / "kill",
"#!/bin/sh\n" 'printf \'%s\\n\' "$*" >> "$VOCULINUX_TEST_KILL_LOG"\n' "exit 0\n",
)
env = os.environ.copy()
env["HOME"] = str(home)
env["PATH"] = os.pathsep.join((str(bin_dir), env.get("PATH", "")))
env["VOCULINUX_TEST_PGREP_LOG"] = str(pgrep_log)
env["VOCULINUX_TEST_KILL_LOG"] = str(kill_log)
env.pop("XDG_DATA_HOME", None)
env.pop("XDG_CONFIG_HOME", None)
env.pop("VIRTUAL_ENV", None)
result = subprocess.run(
["bash", str(script), "-y", *args],
cwd=cwd,
env=env,
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
check=False,
)
if "Checking for running Voculinux processes" in result.stdout:
logged = pgrep_log.read_text(encoding="utf-8") if pgrep_log.is_file() else ""
assert "voculinux" in logged, "uninstall.sh did not use the test pgrep stub"
if kill_log.is_file():
assert kill_log.read_text(encoding="utf-8").strip() == ""
return result
def _output(result: subprocess.CompletedProcess[str]) -> str:
return result.stdout + result.stderr
def test_curl_style_run_leaves_the_callers_directory_alone(tmp_path: Path) -> None:
"""The reported repro: script in /tmp, junk under $PWD, fake home."""
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
_decoy_tree(cwd)
script = tmp_path / "vul.sh"
_write_script(script)
data = home / ".local" / "share" / "voculinux"
config = home / ".config" / "voculinux"
data.mkdir(parents=True)
(data / "marker").write_text("data", encoding="utf-8")
(data / "venv").mkdir()
(data / "venv" / "pyvenv.cfg").write_text("home = /usr\n", encoding="utf-8")
config.mkdir(parents=True)
(config / "config.json").write_text("{}\n", encoding="utf-8")
bindir = home / ".local" / "bin"
bindir.mkdir(parents=True)
(bindir / "voculinux").write_text("#!/bin/sh\n", encoding="utf-8")
(bindir / "voculinux-gui").symlink_to("voculinux")
(bindir / "activate-voculinux.sh").write_text("echo\n", encoding="utf-8")
desktop = home / ".local" / "share" / "applications"
desktop.mkdir(parents=True)
(desktop / "voculinux.desktop").write_text("[Desktop Entry]\n", encoding="utf-8")
result = _run(script, cwd, home)
assert result.returncode == 0, _output(result)
assert "skipping build-artifact cleanup" in result.stdout
assert "Failed to remove some" not in _output(result)
_assert_decoy_intact(cwd)
assert not data.exists()
assert not config.exists()
assert not (bindir / "voculinux").exists()
assert not (bindir / "voculinux-gui").exists()
assert not (bindir / "activate-voculinux.sh").exists()
assert not (desktop / "voculinux.desktop").exists()
def test_keep_flags_still_do_not_touch_the_working_directory(tmp_path: Path) -> None:
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
_decoy_tree(cwd)
script = tmp_path / "vul.sh"
_write_script(script)
config = home / ".config" / "voculinux"
data = home / ".local" / "share" / "voculinux"
config.mkdir(parents=True)
data.mkdir(parents=True)
(config / "config.json").write_text("{}\n", encoding="utf-8")
(data / "marker").write_text("data", encoding="utf-8")
result = _run(script, cwd, home, "--keep-config", "--keep-data")
assert result.returncode == 0, _output(result)
_assert_decoy_intact(cwd)
assert (config / "config.json").is_file()
assert (data / "marker").is_file()
def test_source_checkout_cleans_itself_and_leaves_the_caller_alone(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
checkout = _make_checkout(tmp_path)
_decoy_tree(checkout)
cwd = tmp_path / "cwd"
cwd.mkdir()
_decoy_tree(cwd)
result = _run(checkout / "uninstall.sh", cwd, home)
assert result.returncode == 0, _output(result)
assert "Failed to remove some" not in _output(result)
_assert_decoy_intact(cwd)
assert (checkout / "notes.txt").is_file()
assert (checkout / "proj" / "pkg" / "mod.py").is_file()
assert (checkout / "src" / "vocalinux" / "__init__.py").is_file()
assert (checkout / "pyproject.toml").is_file()
assert not (checkout / "venv").exists()
assert not (checkout / "build").exists()
assert not (checkout / "dist").exists()
assert not (checkout / "bytecode-only.pyc").exists()
assert not (checkout / "bytecode-only.pyo").exists()
assert not (checkout / ".coverage").exists()
assert not (checkout / ".pytest_cache").exists()
assert not (checkout / "proj" / "pkg" / "__pycache__").exists()
assert not (checkout / "src" / "pkg.egg-info").exists()
assert not (checkout / "activate-voculinux.sh").exists()
assert not (checkout / "voculinux-run.py").exists()
def test_dev_venv_records_survive_checkout_cleanup(tmp_path: Path) -> None:
"""`.venv` is the dev environment. Uninstall must not strip its metadata."""
home = tmp_path / "home"
home.mkdir()
checkout = _make_checkout(tmp_path)
_decoy_tree(checkout)
dev_egg = checkout / ".venv" / "lib" / "python3.12" / "site-packages" / "dep.egg-info"
dev_egg.mkdir(parents=True)
(dev_egg / "PKG-INFO").write_text("Metadata-Version: 2.1\n", encoding="utf-8")
dev_cache = checkout / ".venv" / "lib" / "python3.12" / "site-packages" / "dep" / "__pycache__"
dev_cache.mkdir(parents=True)
(dev_cache / "mod.cpython-312.pyc").write_bytes(b"pyc")
(checkout / ".venv" / ".coverage").write_text("keep", encoding="utf-8")
(checkout / "voculinux.egg-info").mkdir()
(checkout / "voculinux.egg-info" / "PKG-INFO").write_text("name\n", encoding="utf-8")
result = _run(checkout / "uninstall.sh", tmp_path, home)
assert result.returncode == 0, _output(result)
assert "Failed to remove some" not in _output(result)
assert (dev_egg / "PKG-INFO").is_file()
assert (dev_cache / "mod.cpython-312.pyc").is_file()
assert (checkout / ".venv" / ".coverage").is_file()
assert not (checkout / "src" / "pkg.egg-info").exists()
assert not (checkout / "voculinux.egg-info").exists()
assert not (checkout / "proj" / "pkg" / "__pycache__").exists()
assert not (checkout / ".coverage").exists()
def test_curl_clone_uninstall_does_not_print_a_cleanup_error(tmp_path: Path) -> None:
"""The cloned repo is a checkout, and uninstall deletes that same directory."""
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
(cwd / "notes.txt").write_text("keep", encoding="utf-8")
clone = home / ".local" / "share" / "voculinux-install"
(clone / "src" / "vocalinux").mkdir(parents=True)
(clone / "pyproject.toml").write_text('[project]\nname = "voculinux"\n', encoding="utf-8")
(clone / "src" / "vocalinux" / "__init__.py").write_text("", encoding="utf-8")
(clone / "src" / "voculinux.egg-info").mkdir()
(clone / "build").mkdir()
(clone / "build" / "out").write_text("build", encoding="utf-8")
_write_script(clone / "uninstall.sh")
result = _run(clone / "uninstall.sh", cwd, home)
assert result.returncode == 0, _output(result)
output = _output(result)
assert "Refusing to clean build artifacts" not in output
assert "[ERROR]" not in output
assert "Failed to remove some" not in output
assert "Uninstallation completed successfully" in result.stdout
assert not clone.exists()
assert (cwd / "notes.txt").read_text(encoding="utf-8") == "keep"
def test_running_inside_the_checkout_still_cleans_it(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
checkout = _make_checkout(tmp_path)
_decoy_tree(checkout)
result = _run(checkout / "uninstall.sh", checkout, home)
assert result.returncode == 0, _output(result)
assert (checkout / "proj" / "pkg" / "mod.py").is_file()
assert not (checkout / "venv").exists()
assert not (checkout / "bytecode-only.pyc").exists()
assert not (checkout / "proj" / "pkg" / "__pycache__").exists()
def test_another_projects_tree_is_not_treated_as_a_checkout(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
project = tmp_path / "other"
(project / "src" / "vocalinux").mkdir(parents=True)
(project / "pyproject.toml").write_text('name = "not-voculinux"\n', encoding="utf-8")
_write_script(project / "uninstall.sh")
_decoy_tree(project)
result = _run(project / "uninstall.sh", project, home)
assert result.returncode == 0, _output(result)
assert "skipping build-artifact cleanup" in result.stdout
_assert_decoy_intact(project)
def test_relative_venv_dir_outside_a_checkout_is_refused(tmp_path: Path) -> None:
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
_decoy_tree(cwd)
script = tmp_path / "vul.sh"
_write_script(script)
result = _run(script, cwd, home, "--venv-dir=venv")
assert result.returncode == 1
assert "Refusing relative --venv-dir" in result.stdout
_assert_decoy_intact(cwd)
def test_relative_venv_dir_resolves_inside_the_checkout(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
checkout = _make_checkout(tmp_path)
(checkout / "custom-venv" / "bin").mkdir(parents=True)
(checkout / "custom-venv" / "bin" / "python").write_text("x", encoding="utf-8")
cwd = tmp_path / "cwd"
cwd.mkdir()
(cwd / "custom-venv" / "bin").mkdir(parents=True)
(cwd / "custom-venv" / "bin" / "python").write_text("keep", encoding="utf-8")
(cwd / "notes.txt").write_text("keep", encoding="utf-8")
result = _run(checkout / "uninstall.sh", cwd, home, "--venv-dir=custom-venv")
assert result.returncode == 0, _output(result)
assert not (checkout / "custom-venv").exists()
assert (cwd / "custom-venv" / "bin" / "python").read_text(encoding="utf-8") == "keep"
assert (cwd / "notes.txt").is_file()
def test_absolute_venv_dir_is_removed_and_the_caller_is_not(tmp_path: Path) -> None:
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
_decoy_tree(cwd)
extra = tmp_path / "custom-venv"
(extra / "bin").mkdir(parents=True)
(extra / "bin" / "python").write_text("x", encoding="utf-8")
script = tmp_path / "vul.sh"
_write_script(script)
result = _run(script, cwd, home, f"--venv-dir={extra}")
assert result.returncode == 0, _output(result)
assert not extra.exists()
_assert_decoy_intact(cwd)
def test_help_does_not_default_to_a_cwd_relative_venv() -> None:
result = subprocess.run(
["bash", str(UNINSTALL_SH), "--help"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, _output(result)
assert "default: venv" not in result.stdout
assert "source checkout" in result.stdout
def test_build_artifact_search_stays_on_one_filesystem() -> None:
source = UNINSTALL_SH.read_text(encoding="utf-8")
cleanup = source.split("cleanup_build_artifacts() {", 1)[1].split("\n}", 1)[0]
finds = [line.strip() for line in cleanup.splitlines() if line.strip().startswith("find ")]
assert finds
assert all("-xdev" in line for line in finds)
def test_uninstaller_is_valid_bash() -> None:
syntax = subprocess.run(
["bash", "-n", str(UNINSTALL_SH)],
capture_output=True,
text=True,
check=False,
)
assert syntax.returncode == 0, syntax.stderr
@pytest.mark.parametrize(
"pyproject",
[
"",
'name = "voculinux"\n',
],
)
def test_checkout_detection_needs_both_the_name_and_the_package_dir(
tmp_path: Path, pyproject: str
) -> None:
"""A pyproject alone, or the package dir without the project name, is not enough."""
home = tmp_path / "home"
home.mkdir()
project = tmp_path / "partial"
project.mkdir()
if pyproject:
(project / "pyproject.toml").write_text(pyproject, encoding="utf-8")
else:
(project / "src" / "vocalinux").mkdir(parents=True)
_write_script(project / "uninstall.sh")
(project / "venv").mkdir()
(project / "venv" / "marker").write_text("keep", encoding="utf-8")
result = _run(project / "uninstall.sh", tmp_path, home)
assert result.returncode == 0, _output(result)
assert (project / "venv" / "marker").is_file()
assert "skipping build-artifact cleanup" in result.stdout