refactor!: port shell scripts to Python package

Bash scripts were untestable and duplicated device/LUKS/mount logic;
the lim/ package centralizes it behind one subprocess wrapper and a
YAML image catalog (single point of truth).

BREAKING CHANGE: scripts/*.sh removed. Use `lim --type <cmd>`; new
types mount/umount/single-boot/raid1-boot/lock/unlock/import/export
replace direct script calls. --extra is deprecated and ignored.

- distributions.yml + lim/catalog.py hold the image catalog (PyYAML)
- pytest suite: 102 tests with mocked subprocess (tests/unit) and a
  250-line max file-length guard (tests/lint)
- ruff strict (select ALL), GitHub Actions CI, Dependabot; Travis gone
- Makefile: install (symlink ~/.local/bin/lim) and test targets
- fixes over bash: SUDO_USER-aware chown, mmcblk/nvme partition paths,
  sha512 checksum support, whole-pipeline failure detection, blkid
  UUID fallback for pre-mounted images, conditional fstab seeding for
  PARTUUID/LABEL images, clean errors for missing binaries
This commit is contained in:
Kevin Veen-Birkenbach
2026-07-14 11:27:49 +02:00
parent c420dd164d
commit ccdef065df
77 changed files with 3402 additions and 1614 deletions

0
tests/__init__.py Normal file
View File

108
tests/conftest.py Normal file
View File

@@ -0,0 +1,108 @@
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lim import runner, ui
from lim.errors import LimError
class FakeRunner:
"""Records every command instead of executing it.
``outputs``/``success_by_fragment``/``failures`` map a substring of the
joined command to the canned behavior.
"""
def __init__(self):
self.calls: list[tuple[str, list, str | None]] = []
self.outputs: dict[str, str] = {}
self.failures: set[str] = set()
self.success_by_fragment: dict[str, bool] = {}
self.sudo_log: list[tuple[list[str], bool]] = []
def _fails(self, cmd: list[str]) -> bool:
joined = " ".join(cmd)
return any(fragment in joined for fragment in self.failures)
def run(self, cmd, *, sudo=False, input_text=None, check=True, error_msg=None):
cmd = [str(part) for part in cmd]
self.calls.append(("run", cmd, input_text))
self.sudo_log.append((cmd, sudo))
returncode = 1 if self._fails(cmd) else 0
if check and returncode != 0:
raise LimError(error_msg or f"Command failed: {' '.join(cmd)}")
return subprocess.CompletedProcess(cmd, returncode)
def output(self, cmd, *, sudo=False, check=True, error_msg=None):
cmd = [str(part) for part in cmd]
self.calls.append(("output", cmd, None))
joined = " ".join(cmd)
for fragment, value in self.outputs.items():
if fragment in joined:
return value
return ""
def succeeds(self, cmd, *, sudo=False):
cmd = [str(part) for part in cmd]
self.calls.append(("succeeds", cmd, None))
joined = " ".join(cmd)
for fragment, value in self.success_by_fragment.items():
if fragment in joined:
return value
return False
def pipeline(self, *cmds, sudo_last=False, error_msg=None):
cmds = [[str(part) for part in cmd] for cmd in cmds]
self.calls.append(("pipeline", cmds, None))
for cmd in cmds:
if self._fails(cmd):
raise LimError(error_msg or "Pipeline failed")
def sync_disks(self):
self.calls.append(("run", ["sync"], None))
def commands(self) -> list[list[str]]:
return [cmd for _, cmd, _ in self.calls]
def find(self, *fragments: str) -> list[list[str]]:
"""All recorded commands whose joined form contains every fragment."""
result = []
for command in self.commands():
joined = " ".join(
" ".join(part) if isinstance(part, list) else part for part in command
)
if all(fragment in joined for fragment in fragments):
result.append(command)
return result
@pytest.fixture
def fake_runner(monkeypatch):
fake = FakeRunner()
monkeypatch.setattr(runner, "run", fake.run)
monkeypatch.setattr(runner, "output", fake.output)
monkeypatch.setattr(runner, "succeeds", fake.succeeds)
monkeypatch.setattr(runner, "pipeline", fake.pipeline)
monkeypatch.setattr(runner, "sync_disks", fake.sync_disks)
return fake
@pytest.fixture
def answers(monkeypatch):
"""Feed scripted answers to ui.ask (and thereby ui.confirm)."""
queue: list[str] = []
def fake_ask(prompt: str) -> str:
assert queue, f"No scripted answer left for prompt: {prompt}"
return queue.pop(0)
monkeypatch.setattr(ui, "ask", fake_ask)
def feed(*items: str) -> None:
queue.extend(items)
return feed

0
tests/lint/__init__.py Normal file
View File

View File

@@ -0,0 +1,19 @@
"""Architecture guard: keep modules small (KISS/SRP)."""
from pathlib import Path
MAX_LINES = 250
REPO_ROOT = Path(__file__).resolve().parents[2]
CHECKED_GLOBS = ("main.py", "lim/**/*.py", "tests/**/*.py")
def test_source_files_stay_below_max_lines():
offenders = {
str(path.relative_to(REPO_ROOT)): length
for pattern in CHECKED_GLOBS
for path in sorted(REPO_ROOT.glob(pattern))
if (length := len(path.read_text().splitlines())) > MAX_LINES
}
assert not offenders, (
f"Files exceeding {MAX_LINES} lines (split them, see KISS/SRP): {offenders}"
)

0
tests/unit/__init__.py Normal file
View File

View File

@@ -0,0 +1,24 @@
from lim import catalog
def test_catalog_contains_all_sections():
assert catalog.arch_rpi_images()
assert catalog.manjaro_gnome_releases()
assert catalog.retropie_images()
assert catalog.mkinitcpio_modules_by_rpi()
def test_arch_entries_are_complete():
for version, entry in catalog.arch_rpi_images().items():
assert entry["image"], version
assert entry["luks_memory_cost"].isdigit(), version
def test_mkinitcpio_covers_every_arch_rpi_version():
assert set(catalog.mkinitcpio_modules_by_rpi()) == set(catalog.arch_rpi_images())
def test_manjaro_entries_have_url_and_image():
for release, entry in catalog.manjaro_gnome_releases().items():
assert entry["url"].startswith("https://"), release
assert entry["image"], release

65
tests/unit/test_cli.py Normal file
View File

@@ -0,0 +1,65 @@
import pytest
from lim import cli, system
from lim.cli import Command
from lim.errors import LimError
@pytest.fixture(autouse=True)
def no_input(monkeypatch):
monkeypatch.setattr(
"builtins.input", lambda *args: (_ for _ in ()).throw(AssertionError("unexpected prompt"))
)
def test_every_command_dispatches_to_its_function(monkeypatch):
executed = []
monkeypatch.setitem(
cli.COMMANDS, "lock", Command(lambda: executed.append("lock"), "d", needs_root=False)
)
cli.main(["--type", "lock", "--auto-confirm"])
assert executed == ["lock"]
def test_needs_root_command_requests_root(monkeypatch):
escalated = []
monkeypatch.setattr(system, "ensure_root", lambda: escalated.append(True))
monkeypatch.setitem(
cli.COMMANDS, "backup", Command(lambda: None, "d", needs_root=True)
)
cli.main(["--type", "backup", "--auto-confirm"])
assert escalated == [True]
def test_confirmation_prompt_waits_for_enter(monkeypatch):
prompts = []
executed = []
monkeypatch.setattr("builtins.input", lambda *args: prompts.append(args) or "")
monkeypatch.setitem(
cli.COMMANDS, "lock", Command(lambda: executed.append(True), "d", needs_root=False)
)
cli.main(["--type", "lock"])
assert len(prompts) == 1
assert executed == [True]
def test_lim_error_exits_with_code_1(monkeypatch):
def failing():
raise LimError("boom")
monkeypatch.setitem(cli.COMMANDS, "lock", Command(failing, "d", needs_root=False))
with pytest.raises(SystemExit) as excinfo:
cli.main(["--type", "lock", "--auto-confirm"])
assert excinfo.value.code == 1
def test_unknown_type_is_rejected_by_argparse():
with pytest.raises(SystemExit) as excinfo:
cli.main(["--type", "does-not-exist"])
assert excinfo.value.code == 2
def test_all_registered_commands_have_descriptions():
for name, command in cli.COMMANDS.items():
assert command.description, name
assert callable(command.func), name

84
tests/unit/test_device.py Normal file
View File

@@ -0,0 +1,84 @@
import pytest
from lim import device
from lim.device import Device
from lim.errors import LimError
class TestPartitionNaming:
def test_letter_suffix_devices_append_number(self):
assert Device("sda").partition(1) == "/dev/sda1"
assert Device("sdb").partition(2) == "/dev/sdb2"
def test_digit_suffix_devices_get_p_infix(self):
assert Device("mmcblk0").partition(1) == "/dev/mmcblk0p1"
assert Device("nvme0n1").partition(2) == "/dev/nvme0n1p2"
class TestOptimalBlocksize:
def test_uses_64_times_physical_block_size(self, tmp_path):
queue = tmp_path / "sda" / "queue"
queue.mkdir(parents=True)
(queue / "physical_block_size").write_text("512\n")
assert device.optimal_blocksize("sda", tmp_path) == str(64 * 512)
def test_falls_back_to_4k_when_missing(self, tmp_path):
assert device.optimal_blocksize("sda", tmp_path) == "4K"
def test_falls_back_to_4k_on_garbage(self, tmp_path):
queue = tmp_path / "sda" / "queue"
queue.mkdir(parents=True)
(queue / "physical_block_size").write_text("not-a-number")
assert device.optimal_blocksize("sda", tmp_path) == "4K"
class TestOverwriteDevice:
@pytest.fixture
def sda(self, monkeypatch):
monkeypatch.setattr(Device, "optimal_blocksize", property(lambda self: "4K"))
return Device("sda")
def test_full_overwrite(self, sda, fake_runner, answers):
answers("y")
device.overwrite_device(sda)
dd_calls = fake_runner.find("dd", "if=/dev/zero", "of=/dev/sda")
assert len(dd_calls) == 1
assert not any("count=" in part for part in dd_calls[0])
@pytest.mark.parametrize("answer", ["", "N"])
def test_skip(self, sda, fake_runner, answers, answer):
answers(answer)
device.overwrite_device(sda)
assert fake_runner.find("dd") == []
def test_block_count(self, sda, fake_runner, answers):
answers("34")
device.overwrite_device(sda)
assert len(fake_runner.find("dd", "count=34", "bs=4K")) == 1
def test_invalid_input_raises(self, sda, fake_runner, answers):
answers("nonsense")
with pytest.raises(LimError):
device.overwrite_device(sda)
class TestSelectDevice:
def test_valid_device(self, fake_runner, answers, monkeypatch):
answers("sda")
monkeypatch.setattr(device, "is_block_device", lambda path: path == "/dev/sda")
assert device.select_device() == Device("sda")
def test_invalid_device_raises(self, fake_runner, answers, monkeypatch):
answers("nope")
monkeypatch.setattr(device, "is_block_device", lambda path: False)
with pytest.raises(LimError):
device.select_device()
def test_is_mounted(fake_runner):
fake_runner.outputs["mount"] = (
"/dev/sda1 on /boot type vfat (rw)\n/dev/mapper/x on /media/x type btrfs (rw)"
)
assert device.is_mounted("/dev/sda1")
assert device.is_mounted("/media/x")
assert not device.is_mounted("/dev/sdb")

35
tests/unit/test_fsutil.py Normal file
View File

@@ -0,0 +1,35 @@
import pytest
from lim import fsutil
from lim.errors import LimError
def test_replace_in_file_replaces_all_occurrences(tmp_path):
target = tmp_path / "conf"
target.write_text("MODULES=()\nHOOKS=(base)\nMODULES=()\n")
fsutil.replace_in_file("MODULES=()", "MODULES=(x)", target)
assert target.read_text() == "MODULES=(x)\nHOOKS=(base)\nMODULES=(x)\n"
def test_replace_in_file_fails_when_search_missing(tmp_path):
target = tmp_path / "conf"
target.write_text("nothing here\n")
with pytest.raises(LimError):
fsutil.replace_in_file("MODULES=()", "MODULES=(x)", target)
assert target.read_text() == "nothing here\n"
def test_ensure_line_appends_once(tmp_path):
target = tmp_path / "fstab"
target.write_text("existing entry\n")
assert fsutil.ensure_line_in_file("new entry", target) is True
assert fsutil.ensure_line_in_file("new entry", target) is False
assert target.read_text() == "existing entry\nnew entry\n"
def test_ensure_line_creates_file_and_handles_missing_newline(tmp_path):
target = tmp_path / "crypttab"
assert fsutil.ensure_line_in_file("first", target) is True
target.write_text("no newline at end")
assert fsutil.ensure_line_in_file("second", target) is True
assert target.read_text() == "no newline at end\nsecond\n"

View File

@@ -0,0 +1,62 @@
from lim.device import Device
from lim.image.session import ImageSession
def test_partition_paths_for_sd_card():
session = ImageSession(Device("mmcblk0"))
assert session.boot_partition_path == "/dev/mmcblk0p1"
assert session.root_partition_path == "/dev/mmcblk0p2"
assert session.root_mapper_path == "/dev/mmcblk0p2"
def test_decrypt_root_on_luks_partition(fake_runner):
fake_runner.outputs["-s TYPE"] = "crypto_LUKS"
fake_runner.outputs["-s UUID"] = "uuid-1"
session = ImageSession(Device("sda"))
session.decrypt_root()
assert session.root_mapper_name == "linux-image-manager-uuid-1"
assert session.root_mapper_path == "/dev/mapper/linux-image-manager-uuid-1"
assert len(fake_runner.find("cryptsetup", "luksOpen", "/dev/sda2")) == 1
def test_decrypt_root_skips_plain_partition(fake_runner):
fake_runner.outputs["-s TYPE"] = "ext4"
session = ImageSession(Device("sda"))
session.decrypt_root()
assert session.root_mapper_name is None
assert session.root_mapper_path == "/dev/sda2"
assert fake_runner.find("cryptsetup") == []
def test_destructor_unmounts_everything_despite_failures(tmp_path, fake_runner):
session = ImageSession(Device("sda"))
session.working_folder = tmp_path
session.boot_mount_path = tmp_path / "boot"
session.root_mount_path = tmp_path / "root"
fake_runner.failures.add("umount") # every umount fails; cleanup must go on
session.destructor()
umounted = [cmd[-1] for cmd in fake_runner.find("umount")]
root = str(session.root_mount_path)
# chroot binds first (deepest path first), then the partitions
assert umounted == [
f"{root}/dev/pts",
f"{root}/dev",
f"{root}/proc",
f"{root}/sys",
f"{root}/boot",
root,
str(session.boot_mount_path),
]
removed = [cmd[-1] for cmd in fake_runner.find("rmdir")]
assert removed == [root, str(session.boot_mount_path), str(tmp_path)]
def test_destructor_closes_luks_mapper(tmp_path, fake_runner):
fake_runner.outputs["-s TYPE"] = "crypto_LUKS"
fake_runner.outputs["-s UUID"] = "uuid-1"
session = ImageSession(Device("sda"))
session.decrypt_root()
session.destructor()
assert len(fake_runner.find("luksClose", "linux-image-manager-uuid-1")) == 1

View File

@@ -0,0 +1,143 @@
import pytest
from lim.errors import LimError
from lim.image import choosers, raspberry, transfer
from lim.image.plan import ImagePlan
from lim.image.session import install_packages
@pytest.fixture
def plan():
return ImagePlan()
class TestDistributionChoosers:
def test_arch_rpi4_uses_aarch64_and_high_memory_cost(self, plan, answers):
answers("4")
choosers.choose_arch(plan)
assert plan.image_name == "ArchLinuxARM-rpi-aarch64-latest.tar.gz"
assert plan.luks_memory_cost == "256000"
assert plan.raspberry_pi_version == "4"
assert plan.download_url == (
"http://os.archlinuxarm.org/os/ArchLinuxARM-rpi-aarch64-latest.tar.gz"
)
def test_arch_rpi1_uses_armv7_and_low_memory_cost(self, plan, answers):
answers("1")
choosers.choose_arch(plan)
assert plan.image_name == "ArchLinuxARM-rpi-armv7-latest.tar.gz"
assert plan.luks_memory_cost == "64000"
def test_arch_unknown_version_raises(self, plan, answers):
answers("99")
with pytest.raises(LimError):
choosers.choose_arch(plan)
def test_manjaro_gnome_25(self, plan, answers):
answers("gnome", "25")
choosers.choose_manjaro(plan)
assert plan.image_name == "manjaro-gnome-25.0.10-251013-linux612.iso"
assert plan.base_download_url == "https://download.manjaro.org/gnome/25.0.10/"
assert plan.image_checksum is None
def test_manjaro_raspberrypi_release_sets_rpi_version(self, plan, answers):
answers("gnome", "raspberrypi")
choosers.choose_manjaro(plan)
assert plan.raspberry_pi_version == "4"
assert plan.luks_memory_cost == "256000"
assert plan.image_name == "Manjaro-ARM-gnome-rpi4-23.02.img.xz"
def test_manjaro_unknown_flavour_raises(self, plan, answers):
answers("kde")
with pytest.raises(LimError):
choosers.choose_manjaro(plan)
def test_retropie_rpi3_shares_rpi2_image(self, plan, answers):
answers("3")
choosers.choose_retropie(plan)
assert plan.image_name == "retropie-buster-4.8-rpi2_3_zero2w.img.gz"
assert plan.image_checksum == "224e64d8820fc64046ba3850f481c87e"
def test_unknown_distribution_raises(self, plan, answers):
answers("gentoo")
with pytest.raises(LimError):
choosers.choose_linux_image(plan)
class TestPartitionInput:
def test_contains_boot_size_and_writes_table(self):
script = transfer.arch_partition_input("+500M")
assert script.startswith("o\n")
assert "\n+500M\n" in script
assert script.endswith("w\n")
class TestDecompressCommand:
@pytest.mark.parametrize(
("name", "expected"),
[
("image.zip", ["unzip", "-p"]),
("image.img.gz", ["gunzip", "-c"]),
("image.iso", ["pv"]),
("image.img.xz", ["unxz", "-c"]),
],
)
def test_known_formats(self, tmp_path, name, expected):
path = tmp_path / name
command = transfer.decompress_command(path)
assert command[: len(expected)] == expected
assert command[-1] == str(path)
def test_unknown_format_raises(self, tmp_path):
with pytest.raises(LimError):
transfer.decompress_command(tmp_path / "image.rar")
class TestInstallPackages:
def test_pacman_for_arch(self, tmp_path, fake_runner):
install_packages("arch", tmp_path, "btrfs-progs")
chroot_calls = [
input_text
for kind, cmd, input_text in fake_runner.calls
if kind == "run" and cmd[0] == "chroot"
]
assert chroot_calls == ["pacman --noconfirm -S --needed btrfs-progs"]
def test_apt_for_retropie(self, tmp_path, fake_runner):
install_packages("retropie", tmp_path, "btrfs-progs")
chroot_calls = [
input_text
for kind, cmd, input_text in fake_runner.calls
if kind == "run" and cmd[0] == "chroot"
]
assert chroot_calls == ["yes | apt install btrfs-progs"]
def test_unsupported_distribution_raises(self, tmp_path, fake_runner):
with pytest.raises(LimError):
install_packages("gentoo", tmp_path, "btrfs-progs")
class TestConfigureHelpers:
def test_configure_sudoers(self, tmp_path):
raspberry.configure_sudoers(tmp_path, "administrator")
sudoers = tmp_path / "etc/sudoers.d/administrator"
assert sudoers.read_text() == "administrator ALL=(ALL:ALL) ALL\n"
assert (sudoers.stat().st_mode & 0o777) == 0o440
def test_configure_ssh_key(self, tmp_path):
public_key = tmp_path / "id_rsa.pub"
public_key.write_text("ssh-rsa AAAA test@host\n")
ssh_folder = tmp_path / "root/home/user/.ssh"
authorized_keys = ssh_folder / "authorized_keys"
raspberry.configure_ssh_key(str(public_key), ssh_folder, authorized_keys)
assert authorized_keys.read_text() == "ssh-rsa AAAA test@host\n"
assert (ssh_folder.stat().st_mode & 0o777) == 0o700
assert (authorized_keys.stat().st_mode & 0o777) == 0o600
def test_configure_ssh_key_missing_source_raises(self, tmp_path):
with pytest.raises(LimError):
raspberry.configure_ssh_key(
str(tmp_path / "missing.pub"),
tmp_path / ".ssh",
tmp_path / ".ssh/authorized_keys",
)

59
tests/unit/test_luks.py Normal file
View File

@@ -0,0 +1,59 @@
import pytest
from lim import luks
from lim.errors import LimError
LUKS_DUMP = """\
LUKS header information
Version: 2
UUID: 1234-abcd-5678
"""
def test_luks_uuid_parsed_from_dump(fake_runner):
fake_runner.outputs["luksDump"] = LUKS_DUMP
assert luks.luks_uuid("/dev/sda1") == "1234-abcd-5678"
def test_luks_uuid_missing_raises(fake_runner):
fake_runner.outputs["luksDump"] = "no uuid here"
with pytest.raises(LimError):
luks.luks_uuid("/dev/sda1")
def test_update_fstab_is_idempotent(tmp_path):
fstab = tmp_path / "fstab"
fstab.write_text("# existing\n")
luks.update_fstab("/dev/mapper/x", "/media/x", fstab_path=fstab)
luks.update_fstab("/dev/mapper/x", "/media/x", fstab_path=fstab)
lines = fstab.read_text().splitlines()
assert lines.count("/dev/mapper/x /media/x btrfs defaults 0 2") == 1
def test_create_luks_key_and_update_crypttab(tmp_path, fake_runner):
fake_runner.outputs["luksDump"] = LUKS_DUMP
key_dir = tmp_path / "luks-keys"
crypttab = tmp_path / "crypttab"
luks.create_luks_key_and_update_crypttab(
"encrypteddrive-sda",
"/dev/sda1",
key_directory=key_dir,
crypttab_path=crypttab,
)
keyfile = key_dir / "encrypteddrive-sda.keyfile"
assert len(fake_runner.find("dd", "if=/dev/urandom", f"of={keyfile}")) == 1
assert len(fake_runner.find("cryptsetup", "luksAddKey", "/dev/sda1")) == 1
assert len(fake_runner.find("cryptsetup", "luksOpen", f"--key-file={keyfile}")) == 1
expected_entry = f"encrypteddrive-sda UUID=1234-abcd-5678 {keyfile} luks"
assert expected_entry in crypttab.read_text().splitlines()
# A second run must not duplicate the crypttab entry.
luks.create_luks_key_and_update_crypttab(
"encrypteddrive-sda",
"/dev/sda1",
key_directory=key_dir,
crypttab_path=crypttab,
)
assert crypttab.read_text().splitlines().count(expected_entry) == 1

View File

@@ -0,0 +1,30 @@
import pytest
from lim import config, packages
from lim.errors import LimError
@pytest.fixture
def package_dir(tmp_path, monkeypatch):
monkeypatch.setattr(config, "PACKAGE_PATH", tmp_path)
return tmp_path
def test_strips_comments_and_blank_lines(package_dir):
(package_dir / "general.txt").write_text(
"# header comment\nnano\ntree# inline comment\n\nhtop\n"
)
assert packages.get_packages("general") == ["nano", "tree", "htop"]
def test_multiple_collections_are_concatenated(package_dir):
(package_dir / "a.txt").write_text("one\n")
subdir = package_dir / "server"
subdir.mkdir()
(subdir / "luks.txt").write_text("two\nthree\n")
assert packages.get_packages("a", "server/luks") == ["one", "two", "three"]
def test_missing_collection_raises(package_dir):
with pytest.raises(LimError):
packages.get_packages("does-not-exist")

View File

@@ -0,0 +1,72 @@
import pytest
from lim.device import Device
from lim.image import raspberry
from lim.image.session import ImageSession
@pytest.fixture
def session(tmp_path):
session = ImageSession(Device("mmcblk0"))
session.root_mount_path = tmp_path
session.boot_partition_uuid = "BOOT-UUID"
return session
class TestSeedBootUuid:
def test_replaces_mmcblk_reference(self, tmp_path, session):
fstab = tmp_path / "etc/fstab"
fstab.parent.mkdir()
fstab.write_text("/dev/mmcblk0p1 /boot vfat defaults 0 0\n")
raspberry._seed_boot_uuid(session)
assert fstab.read_text() == "UUID=BOOT-UUID /boot vfat defaults 0 0\n"
def test_skips_partuuid_based_images(self, tmp_path, session):
fstab = tmp_path / "etc/fstab"
fstab.parent.mkdir()
content = "PARTUUID=6c586e13-01 /boot vfat defaults 0 0\n"
fstab.write_text(content)
raspberry._seed_boot_uuid(session) # must not raise
assert fstab.read_text() == content
class TestEnsureImageMounted:
def test_reads_uuids_when_boot_already_mounted(self, fake_runner):
session = ImageSession(Device("sda"))
fake_runner.outputs["mount"] = "/dev/sda1 on /tmp/x/boot type vfat (rw)"
# Distinct per-partition values so a boot/root swap cannot pass.
fake_runner.outputs["/dev/sda1 -s UUID"] = "boot-uuid"
fake_runner.outputs["/dev/sda2 -s UUID"] = "root-uuid"
fake_runner.outputs["-s TYPE"] = "crypto_LUKS"
raspberry._ensure_image_mounted(session)
assert session.boot_partition_uuid == "boot-uuid"
assert session.root_partition_uuid == "root-uuid"
assert session.root_mapper_name == "linux-image-manager-root-uuid"
assert session.root_mapper_path == "/dev/mapper/linux-image-manager-root-uuid"
def test_plain_root_keeps_partition_as_mapper(self, fake_runner):
session = ImageSession(Device("sda"))
fake_runner.outputs["mount"] = "/dev/sda1 on /tmp/x/boot type vfat (rw)"
fake_runner.outputs["-s UUID"] = "uuid-7"
fake_runner.outputs["-s TYPE"] = "ext4"
raspberry._ensure_image_mounted(session)
assert session.root_mapper_name is None
assert session.root_mapper_path == "/dev/sda2"
def test_mounts_when_nothing_is_mounted(self, fake_runner, tmp_path):
session = ImageSession(Device("sda"))
session.boot_mount_path = tmp_path / "boot"
session.root_mount_path = tmp_path / "root"
fake_runner.outputs["-s TYPE"] = "ext4"
fake_runner.outputs["/dev/sda1 -s UUID"] = "boot-uuid"
fake_runner.outputs["/dev/sda2 -s UUID"] = "root-uuid"
raspberry._ensure_image_mounted(session)
assert len(fake_runner.find("mount", "-v")) == 2
assert session.boot_partition_uuid == "boot-uuid"
assert session.root_partition_uuid == "root-uuid"

92
tests/unit/test_runner.py Normal file
View File

@@ -0,0 +1,92 @@
"""Tests against the real runner module (no fake) using harmless commands."""
import subprocess
import pytest
from lim import runner, ui
from lim.errors import LimError
MISSING = "lim-definitely-missing-binary-x"
class TestMissingBinaries:
def test_run_raises_lim_error(self):
with pytest.raises(LimError, match="Command not found"):
runner.run([MISSING])
def test_run_tolerates_when_check_is_false(self):
result = runner.run([MISSING], check=False)
assert result.returncode == runner.COMMAND_NOT_FOUND
def test_output_raises_lim_error(self):
with pytest.raises(LimError, match="Command not found"):
runner.output([MISSING])
def test_output_tolerates_when_check_is_false(self):
assert runner.output([MISSING], check=False) == ""
def test_succeeds_returns_false(self):
assert runner.succeeds([MISSING]) is False
def test_tolerated_paths_emit_a_warning(self, monkeypatch):
warnings = []
monkeypatch.setattr(ui, "warning", warnings.append)
runner.run([MISSING], check=False)
runner.output([MISSING], check=False)
runner.succeeds([MISSING])
assert len(warnings) == 3
assert all("Command not found" in text for text in warnings)
def test_pipeline_raises_lim_error(self):
with pytest.raises(LimError, match="Command not found"):
runner.pipeline(["echo", "x"], [MISSING])
def test_pipeline_raises_when_first_member_is_missing(self):
with pytest.raises(LimError, match=f"Command not found: {MISSING}"):
runner.pipeline([MISSING], ["cat"])
def test_pipeline_reaps_started_members(self, monkeypatch):
spawned = []
original_popen = subprocess.Popen
def spying_popen(*args, **kwargs):
process = original_popen(*args, **kwargs)
spawned.append(process)
return process
monkeypatch.setattr(subprocess, "Popen", spying_popen)
with pytest.raises(LimError, match="Command not found"):
runner.pipeline(["sleep", "60"], [MISSING])
assert len(spawned) == 1
assert spawned[0].poll() is not None # killed and reaped, not running
assert spawned[0].stdout.closed
def test_run_prefers_explicit_error_message(self):
with pytest.raises(LimError, match="custom message"):
runner.run([MISSING], error_msg="custom message")
def test_output_prefers_explicit_error_message(self):
with pytest.raises(LimError, match="custom message"):
runner.output([MISSING], error_msg="custom message")
class TestHappyPath:
def test_run_returns_completed_process(self):
assert runner.run(["true"]).returncode == 0
def test_run_raises_on_nonzero_exit(self):
with pytest.raises(LimError, match="Command failed with code 1"):
runner.run(["false"])
def test_output_returns_stripped_stdout(self):
assert runner.output(["echo", "hello"]) == "hello"
def test_succeeds_reflects_exit_code(self):
assert runner.succeeds(["true"]) is True
assert runner.succeeds(["false"]) is False
def test_pipeline_runs_and_checks_every_member(self):
runner.pipeline(["echo", "x"], ["cat"]) # must not raise
with pytest.raises(LimError, match="Pipeline failed"):
runner.pipeline(["false"], ["cat"])

View File

@@ -0,0 +1,70 @@
import pytest
from lim import device
from lim.device import Device
from lim.storage import raid1, single_drive
from lim.storage.common import StorageTarget
@pytest.fixture
def block_devices(monkeypatch):
monkeypatch.setattr(device, "is_block_device", lambda path: True)
monkeypatch.setattr(Device, "optimal_blocksize", property(lambda self: "4K"))
def test_storage_target_derives_all_paths():
target = StorageTarget(Device("sdb"))
assert target.mapper_name == "encrypteddrive-sdb"
assert target.mapper_path == "/dev/mapper/encrypteddrive-sdb"
assert target.mount_path == "/media/encrypteddrive-sdb"
assert target.partition_path == "/dev/sdb1"
def test_single_drive_setup_command_sequence(
fake_runner, answers, block_devices, monkeypatch
):
monkeypatch.setattr("lim.system.real_user", lambda: "kevin")
answers("sdb", "N") # device name, skip overwrite
single_drive.setup()
fdisk_calls = [
(cmd, input_text)
for kind, cmd, input_text in fake_runner.calls
if kind == "run" and cmd[0] == "fdisk"
]
assert [input_text for _, input_text in fdisk_calls] == [
single_drive.CREATE_GPT_TABLE_INPUT,
single_drive.CREATE_PARTITION_INPUT,
]
assert len(fake_runner.find("cryptsetup", "-y", "luksFormat", "/dev/sdb1")) == 1
assert len(fake_runner.find("cryptsetup", "luksOpen", "encrypteddrive-sdb")) == 1
assert len(fake_runner.find("mkfs.btrfs", "/dev/mapper/encrypteddrive-sdb")) == 1
assert len(fake_runner.find("mount", "/media/encrypteddrive-sdb")) == 1
assert len(fake_runner.find("chown", "kevin:kevin")) == 1
def test_single_drive_umount(fake_runner, answers, block_devices):
answers("sdb")
single_drive.umount()
assert len(fake_runner.find("umount", "/dev/mapper/encrypteddrive-sdb")) == 1
assert len(fake_runner.find("cryptsetup", "luksClose", "encrypteddrive-sdb")) == 1
def test_raid1_setup_uses_both_whole_devices(fake_runner, answers, block_devices):
answers("sdb", "sdc")
raid1.setup()
assert len(fake_runner.find("cryptsetup", "luksFormat", "/dev/sdb")) == 1
assert len(fake_runner.find("cryptsetup", "luksFormat", "/dev/sdc")) == 1
mkfs = fake_runner.find("mkfs.btrfs", "-m raid1", "-d raid1")
assert mkfs == [
[
"mkfs.btrfs",
"-m",
"raid1",
"-d",
"raid1",
"/dev/mapper/encrypteddrive-sdb",
"/dev/mapper/encrypteddrive-sdc",
]
]

119
tests/unit/test_sync.py Normal file
View File

@@ -0,0 +1,119 @@
from pathlib import Path
import pytest
from lim import config, system
from lim.data import sync
from lim.errors import LimError
@pytest.fixture
def home(tmp_path):
home = tmp_path / "home/kevin"
(home / ".ssh").mkdir(parents=True)
(home / ".ssh/id_rsa").write_text("key")
(home / ".gitconfig").write_text("[user]")
return home
@pytest.fixture
def data_path(tmp_path):
return tmp_path / "decrypted/data"
@pytest.fixture
def backup_folder(tmp_path):
return tmp_path / "decrypted/backup/import/20260707000000"
def test_import_plan_only_covers_existing_items(home, data_path, backup_folder):
operations = sync.build_sync_plan("import", home, data_path, backup_folder)
sources = [operation.source for operation in operations]
assert sources == [f"{home}/.ssh/", f"{home}/.gitconfig"]
for operation in operations:
assert operation.destination.startswith(str(data_path))
def test_export_swaps_source_and_destination(home, data_path, backup_folder):
exported = Path(f"{data_path}{home}/.gitconfig")
exported.parent.mkdir(parents=True)
exported.write_text("[user]")
operations = sync.build_sync_plan("export", home, data_path, backup_folder)
assert operations == [
sync.SyncOperation(
source=str(exported),
destination=f"{home}/.gitconfig",
backup_dir=str(Path(f"{backup_folder}{home}")),
is_directory=False,
)
]
def test_unknown_mode_raises(home, data_path, backup_folder):
with pytest.raises(LimError):
sync.build_sync_plan("sideways", home, data_path, backup_folder)
def test_rsync_command_uses_delete_only_for_directories():
directory_op = sync.SyncOperation("src/", "dst/", "backup", is_directory=True)
file_op = sync.SyncOperation("src", "dst", "backup", is_directory=False)
assert sync.rsync_command(directory_op) == [
"rsync", "-abcEPuvW", "--delete", "--backup-dir=backup", "src/", "dst/"
]
assert sync.rsync_command(file_op) == [
"rsync", "-abcEPuvW", "--backup-dir=backup", "src", "dst"
]
def test_import_syncs_from_real_home(tmp_path, fake_runner, monkeypatch):
real_home = tmp_path / "home/kevin"
real_home.mkdir(parents=True)
(real_home / ".gitconfig").write_text("[user]")
monkeypatch.setattr(system, "real_home", lambda: real_home)
monkeypatch.setattr(config, "DECRYPTED_PATH", tmp_path / "decrypted")
monkeypatch.setattr(config, "DATA_PATH", tmp_path / "decrypted/data")
monkeypatch.setattr(config, "BACKUP_PATH", tmp_path / "decrypted/backup")
fake_runner.outputs["mount"] = f"encfs on {tmp_path / 'decrypted'} type fuse"
sync.import_from_system()
rsync_calls = fake_runner.find("rsync")
assert len(rsync_calls) == 1
assert rsync_calls[0][-2] == str(real_home / ".gitconfig")
def test_export_chowns_real_home_without_sudo(tmp_path, fake_runner, monkeypatch):
real_home = tmp_path / "home/kevin"
(real_home / ".ssh").mkdir(parents=True)
(real_home / ".ssh/id_rsa").write_text("key")
monkeypatch.setattr(system, "real_home", lambda: real_home)
monkeypatch.setattr(system, "real_user", lambda: "kevin")
monkeypatch.setattr(config, "DECRYPTED_PATH", tmp_path / "decrypted")
monkeypatch.setattr(config, "DATA_PATH", tmp_path / "decrypted/data")
monkeypatch.setattr(config, "BACKUP_PATH", tmp_path / "decrypted/backup")
fake_runner.outputs["mount"] = f"encfs on {tmp_path / 'decrypted'} type fuse"
sync.export_to_system()
chown_calls = [
(cmd, sudo) for cmd, sudo in fake_runner.sudo_log if cmd[0] == "chown"
]
assert chown_calls == [
(["chown", "-R", "kevin:kevin", str(real_home)], False)
]
assert (real_home / ".ssh/id_rsa").stat().st_mode & 0o777 == 0o600
def test_execute_sync_plan_creates_folders_and_runs_rsync(
tmp_path, fake_runner
):
operation = sync.SyncOperation(
source=str(tmp_path / "src.txt"),
destination=str(tmp_path / "deep/nested/dst.txt"),
backup_dir=str(tmp_path / "backup/deep"),
is_directory=False,
)
sync.execute_sync_plan([operation])
assert (tmp_path / "deep/nested").is_dir()
assert (tmp_path / "backup/deep").is_dir()
assert len(fake_runner.find("rsync", "-abcEPuvW")) == 1

52
tests/unit/test_verify.py Normal file
View File

@@ -0,0 +1,52 @@
import hashlib
import pytest
from lim.errors import LimError
from lim.image import verify
CONTENT = b"fake image content"
@pytest.fixture
def image(tmp_path):
path = tmp_path / "image.img"
path.write_bytes(CONTENT)
return path
@pytest.mark.parametrize("algorithm", ["md5", "sha1", "sha256", "sha512"])
def test_matching_checksum_passes(image, algorithm):
checksum = hashlib.new(algorithm, CONTENT).hexdigest()
verify.verify_checksum(image, checksum)
def test_uppercase_checksum_passes(image):
checksum = hashlib.sha256(CONTENT).hexdigest().upper()
verify.verify_checksum(image, checksum)
def test_wrong_checksum_raises(image):
checksum = hashlib.sha256(b"other content").hexdigest()
with pytest.raises(LimError):
verify.verify_checksum(image, checksum)
def test_unrecognized_digest_length_raises(image):
with pytest.raises(LimError):
verify.verify_checksum(image, "abc123")
def test_missing_checksum_is_skipped(image):
verify.verify_checksum(image, None) # must not raise
def test_resolve_checksum_takes_first_available(fake_runner):
fake_runner.success_by_fragment["image.img.sha1"] = False
fake_runner.success_by_fragment["image.img.sha512"] = True
fake_runner.outputs["-q -O -"] = "deadbeef image.img"
assert verify.resolve_checksum("https://example.org/image.img") == "deadbeef"
def test_resolve_checksum_returns_none_without_sources(fake_runner):
assert verify.resolve_checksum("https://example.org/image.img") is None