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

View File

@@ -1,7 +1,12 @@
{
"env": {
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "commit.gpgsign",
"GIT_CONFIG_VALUE_0": "false"
},
"permissions": {
"defaultMode": "dontAsk",
"deny": [
"ask": [
"Bash(git push)",
"Bash(git push:*)",
"Bash(git commit)",

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"

22
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: tests
on:
push:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest pyyaml
- run: make test

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@ decrypted/
.encrypted/
*package-lock.json
log.txt
__pycache__/
*.pyc
.pytest_cache/

View File

@@ -1,2 +0,0 @@
language: shell
script: shellcheck $(find . -type f -name '*.sh')

12
Makefile Normal file
View File

@@ -0,0 +1,12 @@
.PHONY: install test
PREFIX ?= $(HOME)/.local
install:
chmod +x main.py
install -d $(PREFIX)/bin
ln -sf $(CURDIR)/main.py $(PREFIX)/bin/lim
@echo "Installed lim to $(PREFIX)/bin/lim"
test:
python3 -m pytest

146
README.md
View File

@@ -4,17 +4,16 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](./LICENSE.txt) [![GitHub stars](https://img.shields.io/github/stars/kevinveenbirkenbach/linux-image-manager.svg?style=social)](https://github.com/kevinveenbirkenbach/linux-image-manager/stargazers)
Linux Image Manager (lim) is a powerful collection of shell scripts for downloading, configuring, and managing Linux images. Whether you're setting up encrypted storage, configuring a virtual Btrfs RAID1, performing backups, or chrooting into an image, this tool makes Linux image administration simple and efficient. 🚀
> **Note:** In this project, `lim` is an alias for the **main.py** wrapper script which orchestrates the execution of the various shell scripts.
Linux Image Manager (lim) is a Python tool for downloading, configuring, and managing Linux images. Whether you're setting up encrypted storage, configuring a virtual Btrfs RAID1, performing backups, or chrooting into an image, this tool makes Linux image administration simple and efficient. 🚀
## Features ✨
- **Image Download & Setup:** Automatically download and prepare Linux distributions.
- **Image Download & Setup:** Automatically download, verify (checksum + GPG signature) and prepare Linux distributions.
- **Encrypted Storage:** Configure LUKS encryption for secure image management.
- **Virtual RAID1:** Easily set up virtual Btrfs RAID1 for data redundancy.
- **Backup & Restore:** Create image backups from devices using dd.
- **Chroot Environment:** Easily enter a chroot shell to maintain or modify Linux images.
- **Data Import/Export:** Sync personal data into an encfs-encrypted store and back.
- **Automated Procedures:** Simplify partitioning, formatting, mounting, and more.
## Installation 📦
@@ -25,96 +24,78 @@ Install Linux Image Manager quickly using [Kevin's Package Manager](https://gith
package-manager install lim
```
This command makes Linux Image Manager globally available as `lim` in your terminal. The `lim` alias points to the **main.py** wrapper script.
This command makes Linux Image Manager globally available as `lim` in your terminal. The `lim` alias points to the **main.py** entry point.
There are no Python dependencies beyond the standard library. The commands call the usual system tools (`cryptsetup`, `fdisk`, `dd`, `rsync`, `wget`, `gpg`, `encfs`, `pv`, `bsdtar`, ...), so those need to be installed for the command you use.
## Usage ⚙️
The **main.py** wrapper provides a unified interface to run the different shell scripts included in this project. It supports various script types and allows you to pass additional parameters. The built-in `--help` option displays detailed usage information.
`lim` provides a unified interface for all image and storage operations. Commands that need root privileges re-execute themselves with `sudo` automatically. The built-in `--help` option displays detailed usage information.
### Available Script Types
### Available Command Types
- **Image Setup (`--type image`):**
Executes the Linux image setup located at `scripts/image/setup.sh`. This setup:
- Creates partitions and formats them.
- Transfers the Linux image file to the device.
- Configures boot and root partitions.
- **Single Drive Encryption Setup (`--type single`):**
Executes the single-drive encryption setup from `scripts/encryption/storage/single_drive/setup.sh`. This setup:
- Sets up disk encryption using LUKS on one drive.
- Configures a Btrfs file system for secure storage.
- **RAID1 Encryption Setup (`--type raid1`):**
Executes the RAID1 encryption setup found at `scripts/encryption/storage/raid1/setup.sh`. This setup:
- Configures a virtual RAID1 with two drives.
- Uses LUKS encryption and a Btrfs RAID1 file system for redundancy.
- **Backup Image Setup (`--type backup`):**
Executes the backup image setup located at `scripts/image/backup.sh`. This setup:
- Creates an image backup from a memory device to a file.
- Uses `dd` to transfer the image from the specified device to an image file.
- **Chroot Environment Setup (`--type chroot`):**
Executes the chroot setup from `scripts/image/chroot.sh`. This setup:
- Mounts partitions and configures the chroot environment for a Linux image.
- Provides a shell within the Linux image for system maintenance.
| `--type` | Description |
|---------------|-----------------------------------------------------------------------------|
| `image` | Download, verify and transfer a Linux image to a device, incl. optional LUKS encryption and Raspberry Pi configuration. |
| `single` | Set up LUKS encryption with Btrfs on a single drive. |
| `raid1` | Set up an encrypted virtual Btrfs RAID1 across two drives. |
| `backup` | Create an image backup from a memory device using dd. |
| `chroot` | Mount an image and open a shell inside it. |
| `mount` | Unlock and mount an encrypted drive. |
| `umount` | Unmount an encrypted drive and close the mapper. |
| `single-boot` | Register a single encrypted drive for automount on boot (keyfile, crypttab, fstab). |
| `raid1-boot` | Register an encrypted RAID1 for automount on boot. |
| `unlock` | Decrypt the encfs data store. |
| `lock` | Lock the encfs data store. |
| `import` | Import personal data from the system into the encrypted store. |
| `export` | Export personal data from the encrypted store back to the system. |
### Command-Line Options
- **`--type`**
**(Required)** Choose the type of script to execute. Options include: `image`, `single`, `raid1`, `backup`, and `chroot`.
- **`--extra`**
**(Optional)** Pass any extra parameters directly to the selected shell script.
- **`--auto-confirm`**
**(Optional)** Automatically bypass the confirmation prompt before executing the selected script.
- **`--help`**
**(Optional)** Displays detailed help information about the command-line options and usage of the wrapper. Simply run:
```bash
lim --help
```
to view the complete help message.
- **`--type`** *(required)*: Choose the command to execute (see table above).
- **`--auto-confirm`** *(optional)*: Bypass the confirmation prompt before execution.
- **`--help`** *(optional)*: Display detailed help information.
### Example Commands
- **Display Help:**
```bash
lim --help
```
```bash
# Display help
lim --help
- **Show Information About the Image Setup:**
```bash
lim --type image --info
```
# Execute the Linux image setup
lim --type image
- **Execute the Linux Image Setup (with extra parameters):**
```bash
lim --type image --extra --some-option value
```
# Run the single drive encryption setup without a confirmation prompt
lim --type single --auto-confirm
- **Run the Single Drive Encryption Setup without a confirmation prompt:**
```bash
lim --type single --auto-confirm
```
# Set up an encrypted RAID1
lim --type raid1
- **Execute the RAID1 Encryption Setup:**
```bash
lim --type raid1
```
# Back up a memory device to an image file
lim --type backup
- **Perform a Backup of an Image:**
```bash
lim --type backup
```
# Enter a chroot environment for a Linux image
lim --type chroot
```
- **Enter a Chroot Environment for a Linux Image:**
```bash
lim --type chroot
```
## Project Structure 🗂️
For additional details on each script and further configuration options, please refer to the `scripts/` and `configuration/` directories.
```
main.py # entry point (the `lim` alias)
distributions.yml # single point of truth for the image catalog
lim/
cli.py # argument parsing and command dispatch
catalog.py # read-only access to distributions.yml
device.py # block device selection, dd, blkid helpers
luks.py # LUKS keyfiles, crypttab/fstab bookkeeping
runner.py # subprocess wrapper used by all modules
storage/ # single drive and RAID1 encryption setups
image/ # image setup, backup, chroot, verification
data/ # encfs lock/unlock and data import/export
configuration/ # package collections used during image setup
tests/unit/ # unit tests (all external commands mocked)
tests/lint/ # architecture guards (e.g. max file length)
```
## Configuration & Customization 🔧
@@ -122,15 +103,22 @@ Customize your environment in the `configuration/` folder:
- **General Packages:** Contains common packages for all setup scripts.
- **Server LUKS Packages:** Contains packages needed for setting up LUKS encryption on servers.
## Development & Tests 🧪
The test suite mocks all external commands, so it runs safely on any machine:
```bash
pytest
```
## License 📜
This project is licensed under the GNU General Public License Version 3. See the [LICENSE.txt](./LICENSE.txt) file for details.
## Contact & Support 💬
- **Author:** Kevin Veen-Birkenbach
- **Email:** [kevin@veen.world](mailto:kevin@veen.world)
- **Author:** Kevin Veen-Birkenbach
- **Email:** [kevin@veen.world](mailto:kevin@veen.world)
- **Website:** [https://www.veen.world/](https://www.veen.world/)
Feel free to contribute, report issues, or get in touch. Happy Linux managing! 😊
```

67
distributions.yml Normal file
View File

@@ -0,0 +1,67 @@
# Single point of truth for the downloadable image catalog.
# Consumed by lim/catalog.py.
# Image and LUKS --pbkdf-memory cost per Raspberry Pi version.
arch_rpi_images:
"1":
image: ArchLinuxARM-rpi-armv7-latest.tar.gz
luks_memory_cost: "64000"
"2":
image: ArchLinuxARM-rpi-armv7-latest.tar.gz
luks_memory_cost: "128000"
"3b":
image: ArchLinuxARM-rpi-aarch64-latest.tar.gz
luks_memory_cost: "128000"
"3b+":
image: ArchLinuxARM-rpi-aarch64-latest.tar.gz
luks_memory_cost: "128000"
"4":
image: ArchLinuxARM-rpi-aarch64-latest.tar.gz
luks_memory_cost: "256000"
manjaro_gnome_releases:
"20":
checksum: 2df3697908483550d4a473815b08c1377e6b6892
url: https://osdn.net/projects/manjaro-archive/storage/gnome/20.0/
image: manjaro-gnome-20.0-200426-linux56.iso
"21":
url: https://download.manjaro.org/gnome/21.3.7/
image: manjaro-gnome-21.3.7-220816-linux515.iso
"22":
url: https://download.manjaro.org/gnome/22.1.3/
image: manjaro-gnome-22.1.3-230529-linux61.iso
"24":
url: https://download.manjaro.org/gnome/24.2.1/
image: manjaro-gnome-24.2.1-241216-linux612.iso
"25":
url: https://download.manjaro.org/gnome/25.0.10/
image: manjaro-gnome-25.0.10-251013-linux612.iso
# At the moment just optimized for Raspberry Pi 4.
raspberrypi:
url: https://github.com/manjaro-arm/rpi4-images/releases/download/23.02/
image: Manjaro-ARM-gnome-rpi4-23.02.img.xz
luks_memory_cost: "256000"
raspberry_pi_version: "4"
retropie_images:
"1":
checksum: 95a6f84453df36318830de7e8507170e
image: retropie-buster-4.8-rpi1_zero.img.gz
"2":
checksum: 224e64d8820fc64046ba3850f481c87e
image: retropie-buster-4.8-rpi2_3_zero2w.img.gz
"3":
checksum: 224e64d8820fc64046ba3850f481c87e
image: retropie-buster-4.8-rpi2_3_zero2w.img.gz
"4":
checksum: b5daa6e7660a99c246966f3f09b4014b
image: retropie-buster-4.8-rpi4_400.img.gz
# Extra kernel modules the initramfs needs for early network access,
# see https://raspberrypi.stackexchange.com/questions/67051
mkinitcpio_modules_by_rpi:
"1": ""
"2": ""
"3b": smsc95xx
"3b+": lan78xx
"4": lan78xx

3
lim/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Linux Image Manager — administration tool for Linux images and encrypted storage."""
__version__ = "1.0.0"

31
lim/catalog.py Normal file
View File

@@ -0,0 +1,31 @@
"""Read-only access to the image catalog (distributions.yml in the repo root)."""
from functools import cache
import yaml
from lim import config
CATALOG_PATH = config.REPOSITORY_PATH / "distributions.yml"
@cache
def _load() -> dict:
with CATALOG_PATH.open() as handle:
return yaml.safe_load(handle)
def arch_rpi_images() -> dict[str, dict[str, str]]:
return _load()["arch_rpi_images"]
def manjaro_gnome_releases() -> dict[str, dict[str, str]]:
return _load()["manjaro_gnome_releases"]
def retropie_images() -> dict[str, dict[str, str]]:
return _load()["retropie_images"]
def mkinitcpio_modules_by_rpi() -> dict[str, str]:
return _load()["mkinitcpio_modules_by_rpi"]

167
lim/cli.py Normal file
View File

@@ -0,0 +1,167 @@
"""Command line interface: `lim --type <command>`."""
import argparse
import sys
from collections.abc import Callable
from dataclasses import dataclass
from lim import system, ui
from lim.data import crypt, sync
from lim.errors import LimError
from lim.image import backup, chroot
from lim.image import setup as image_setup
from lim.storage import raid1, single_drive
@dataclass(frozen=True)
class Command:
func: Callable[[], None]
description: str
needs_root: bool
COMMANDS: dict[str, Command] = {
"image": Command(
image_setup.run_setup,
"Linux Image Setup:\n"
" - Creates partitions and formats them.\n"
" - Transfers the Linux image file to the device.\n"
" - Configures boot and root partitions.",
needs_root=True,
),
"single": Command(
single_drive.setup,
"Single Drive Encryption Setup:\n"
" - Sets up disk encryption using LUKS on one drive.\n"
" - Configures a Btrfs file system for secure storage.",
needs_root=True,
),
"raid1": Command(
raid1.setup,
"RAID1 Encryption Setup:\n"
" - Configures a virtual RAID1 with two drives.\n"
" - Uses LUKS encryption and a Btrfs RAID1 file system for redundancy.",
needs_root=True,
),
"backup": Command(
backup.run_backup,
"Backup Image Setup:\n"
" - Creates an image backup from a memory device to a file.\n"
" - Uses dd to transfer the image from the specified device to an image file.",
needs_root=True,
),
"chroot": Command(
chroot.run_chroot,
"Chroot Environment Setup:\n"
" - Mounts partitions and configures the chroot environment for a Linux image.\n"
" - Provides a shell within the Linux image for system maintenance.",
needs_root=True,
),
"mount": Command(
single_drive.mount,
"Mount Encrypted Storage:\n"
" - Unlocks a LUKS partition and mounts it.",
needs_root=True,
),
"umount": Command(
single_drive.umount,
"Unmount Encrypted Storage:\n"
" - Unmounts a LUKS partition and closes the mapper.",
needs_root=True,
),
"single-boot": Command(
single_drive.mount_on_boot,
"Single Drive Automount Setup:\n"
" - Creates a LUKS keyfile and registers it in crypttab and fstab.",
needs_root=True,
),
"raid1-boot": Command(
raid1.mount_on_boot,
"RAID1 Automount Setup:\n"
" - Creates LUKS keyfiles for both drives and registers them in crypttab/fstab.",
needs_root=True,
),
"unlock": Command(
crypt.unlock,
"Unlock Data:\n"
" - Decrypts the encfs data store into the decrypted folder.",
needs_root=False,
),
"lock": Command(
crypt.lock,
"Lock Data:\n"
" - Unmounts the decrypted encfs folder.",
needs_root=False,
),
"import": Command(
sync.import_from_system,
"Import Data:\n"
" - Copies personal data from the system into the encrypted store.",
needs_root=False,
),
"export": Command(
sync.export_to_system,
"Export Data:\n"
" - Copies personal data from the encrypted store back to the system.",
needs_root=False,
),
}
def build_parser() -> argparse.ArgumentParser:
epilog_lines = ["Available script types:"]
epilog_lines += [
f" {name:<12} - {command.description.splitlines()[0].rstrip(':')}"
for name, command in COMMANDS.items()
]
parser = argparse.ArgumentParser(
prog="lim",
description="Linux Image Manager — manages Linux images and encrypted storage.",
epilog="\n".join(epilog_lines),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--type",
required=True,
choices=list(COMMANDS.keys()),
help="Select the command to execute.",
)
parser.add_argument(
"--auto-confirm",
action="store_true",
help="Automatically confirm execution without prompting the user.",
)
parser.add_argument(
"--extra",
nargs=argparse.REMAINDER,
default=[],
help="Deprecated, ignored. Former extra parameters for the shell scripts.",
)
return parser
def main(argv: list[str] | None = None) -> None:
args = build_parser().parse_args(argv)
command = COMMANDS[args.type]
if args.extra:
ui.warning("--extra is deprecated and ignored.")
if command.needs_root:
system.ensure_root()
ui.header()
ui.info(f"Selected command: {args.type}")
ui.info("Description:")
print(command.description)
try:
if not args.auto_confirm:
input("Press Enter to execute or Ctrl+C to cancel...")
command.func()
except LimError as exc:
ui.error(f"{exc} -> Leaving program.")
sys.exit(1)
except KeyboardInterrupt:
print()
ui.error("Execution aborted by user.")
sys.exit(1)

11
lim/config.py Normal file
View File

@@ -0,0 +1,11 @@
"""Repository paths shared by all modules."""
from pathlib import Path
REPOSITORY_PATH = Path(__file__).resolve().parent.parent
CONFIGURATION_PATH = REPOSITORY_PATH / "configuration"
PACKAGE_PATH = CONFIGURATION_PATH / "packages"
ENCRYPTED_PATH = REPOSITORY_PATH / ".encrypted"
DECRYPTED_PATH = REPOSITORY_PATH / "decrypted"
DATA_PATH = DECRYPTED_PATH / "data"
BACKUP_PATH = DECRYPTED_PATH / "backup"

0
lim/data/__init__.py Normal file
View File

24
lim/data/crypt.py Normal file
View File

@@ -0,0 +1,24 @@
"""Lock/unlock the encfs-encrypted data directory."""
from lim import config, runner, ui
def unlock() -> None:
ui.info(f"Unlocking directory {config.DECRYPTED_PATH}...")
if not config.DECRYPTED_PATH.is_dir():
ui.info(f"Creating directory {config.DECRYPTED_PATH}...")
config.DECRYPTED_PATH.mkdir()
ui.info(f"Decrypting directory {config.ENCRYPTED_PATH} to {config.DECRYPTED_PATH}...")
runner.run(["encfs", str(config.ENCRYPTED_PATH), str(config.DECRYPTED_PATH)])
print("ATTENTION: DATA IS NOW DECRYPTED!")
def lock() -> None:
ui.info(f"Locking directory {config.DECRYPTED_PATH}...")
runner.run(
["fusermount", "-u", str(config.DECRYPTED_PATH)],
error_msg="Unmounting failed.",
)
ui.info("Data is now encrypted.")
ui.info(f"Removing directory {config.DECRYPTED_PATH}...")
config.DECRYPTED_PATH.rmdir()

135
lim/data/sync.py Normal file
View File

@@ -0,0 +1,135 @@
"""Import personal data from the system into the encrypted store, or export it back."""
import time
from dataclasses import dataclass
from pathlib import Path
from lim import config, runner, system, ui
from lim.data import crypt
from lim.errors import LimError
# Paths relative to $HOME; trailing "/" marks a directory tree.
BACKUP_ITEMS = [
".ssh/",
".gitconfig",
".atom/config.cson",
".projectlibre/projectlibre.conf",
".local/share/rhythmbox/rhythmdb.xml",
".config/keepassxc/keepassxc.ini",
"Documents/certificates/",
"Documents/security/",
"Documents/identity/",
"Documents/health/",
"Documents/licenses/",
]
@dataclass(frozen=True)
class SyncOperation:
source: str
destination: str
backup_dir: str
is_directory: bool
def build_sync_plan(
mode: str,
home: Path,
data_path: Path,
backup_folder: Path,
) -> list[SyncOperation]:
"""One rsync operation per existing backup item; missing sources are skipped."""
operations = []
for item in BACKUP_ITEMS:
is_directory = item.endswith("/")
system_path = home / item
# The store mirrors the absolute system path below DATA_PATH.
data_path_item = Path(f"{data_path}{system_path}")
if mode == "export":
source, destination = data_path_item, system_path
elif mode == "import":
source, destination = system_path, data_path_item
else:
raise LimError(f"Unknown sync mode: {mode}")
ui.info(f"{mode.capitalize()} data from {source} to {destination}...")
if not (source.is_file() or source.is_dir()):
ui.warning(f"{source} doesn't exist. Copying data is not possible.")
continue
if is_directory:
backup_dir = Path(f"{backup_folder}{system_path}")
else:
backup_dir = Path(f"{backup_folder}{system_path}").parent
operations.append(
SyncOperation(
source=f"{source}/" if is_directory else str(source),
destination=f"{destination}/" if is_directory else str(destination),
backup_dir=str(backup_dir),
is_directory=is_directory,
)
)
return operations
def rsync_command(operation: SyncOperation) -> list[str]:
command = ["rsync", "-abcEPuvW"]
if operation.is_directory:
command.append("--delete")
command += [f"--backup-dir={operation.backup_dir}", operation.source, operation.destination]
return command
def execute_sync_plan(operations: list[SyncOperation]) -> None:
for operation in operations:
Path(operation.backup_dir).mkdir(parents=True, exist_ok=True)
destination_dir = (
Path(operation.destination)
if operation.is_directory
else Path(operation.destination).parent
)
destination_dir.mkdir(parents=True, exist_ok=True)
if not operation.is_directory and Path(operation.destination).is_file():
ui.info("The destination file already exists!")
ui.info("Difference:")
runner.run(
["diff", operation.destination, operation.source], check=False
)
runner.run(rsync_command(operation))
def ensure_unlocked() -> None:
mounts = runner.output(["mount"], check=False)
if str(config.DECRYPTED_PATH) not in mounts:
ui.info(
f"The decrypted folder {config.DECRYPTED_PATH} is locked. "
"You need to unlock it!"
)
crypt.unlock()
def import_from_system(mode: str = "import") -> None:
ensure_unlocked()
backup_folder = (
config.BACKUP_PATH / mode / time.strftime("%Y%m%d%H%M%S")
)
backup_folder.mkdir(parents=True, exist_ok=True)
operations = build_sync_plan(
mode, system.real_home(), config.DATA_PATH, backup_folder
)
execute_sync_plan(operations)
def export_to_system() -> None:
import_from_system(mode="export")
user = system.real_user()
home = system.real_home()
ui.info("Setting right permissions for imported files...")
try:
(home / ".ssh").chmod(0o700)
for child in (home / ".ssh").rglob("*"):
child.chmod(0o700 if child.is_dir() else 0o600)
except OSError as exc:
raise LimError(f"Failed to set correct ssh permissions: {exc}") from exc
# Best effort without privilege escalation, like the original script.
chowned = runner.run(["chown", "-R", f"{user}:{user}", str(home)], check=False)
if chowned.returncode != 0:
ui.warning(f'Not all files could be owned by user "{user}"...')

130
lim/device.py Normal file
View File

@@ -0,0 +1,130 @@
"""Block-device selection and low-level device helpers."""
import stat as stat_module
from dataclasses import dataclass
from pathlib import Path
from lim import runner, ui
from lim.errors import LimError
SYS_BLOCK_PATH = Path("/sys/block")
PARTITION_TABLE_INFO = """\
##########################################################################################
Note on Partition Table Deletion:
---------------------------------------------
• MBR (Master Boot Record):
- Typically occupies the first sector (512 bytes), i.e., 1 block.
• GPT (GUID Partition Table):
- Uses a protective MBR (1 block), a GPT header (1 block),
and usually a partition entry array that takes up about 32 blocks.
- Total: approximately 34 blocks (assuming a 512-byte block size).
Recommendation: For deleting a GPT partition table, use a block size of 512 bytes
and overwrite at least 34 blocks to ensure the entire table is cleared.
##########################################################################################"""
def optimal_blocksize(name: str, sys_block_path: Path = SYS_BLOCK_PATH) -> str:
"""64 * physical block size, or "4K" when the size cannot be read.
See https://www.heise.de/ct/hotline/Optimale-Blockgroesse-fuer-dd-2056768.html
"""
size_path = sys_block_path / name / "queue" / "physical_block_size"
try:
return str(64 * int(size_path.read_text().strip()))
except (OSError, ValueError):
return "4K"
@dataclass(frozen=True)
class Device:
name: str # e.g. "sda" or "mmcblk0"
@property
def path(self) -> str:
return f"/dev/{self.name}"
@property
def optimal_blocksize(self) -> str:
return optimal_blocksize(self.name)
def partition(self, number: int) -> str:
"""/dev/sda -> /dev/sda1, /dev/mmcblk0 -> /dev/mmcblk0p1."""
if self.name[-1].isdigit():
return f"{self.path}p{number}"
return f"{self.path}{number}"
def is_block_device(path: str) -> bool:
try:
return stat_module.S_ISBLK(Path(path).stat().st_mode)
except OSError:
return False
def select_device() -> Device:
ui.info("Available devices:")
runner.run(["lsblk", "-o", "NAME,SIZE,TYPE,MODEL"], check=False)
name = ui.ask("Please type in the name of the device: /dev/")
device = Device(name)
if not name or not is_block_device(device.path):
raise LimError(f"{device.path} is not a valid device.")
ui.info(f"Device path set to: {device.path}")
ui.info(f"Optimal blocksize set to: {device.optimal_blocksize}")
return device
def overwrite_device(device: Device) -> None:
"""Optionally overwrite the device (or its first blocks) with zeros."""
print(PARTITION_TABLE_INFO)
answer = ui.ask(
f"Should {device.path} be overwritten with zeros before copying? (y/N/block count)"
)
if answer == "y":
ui.info("Overwriting entire device...")
runner.run(
[
"dd",
"if=/dev/zero",
f"of={device.path}",
f"bs={device.optimal_blocksize}",
"status=progress",
],
sudo=True,
error_msg=f"Overwriting {device.path} failed.",
)
runner.sync_disks()
elif answer in ("", "N"):
ui.info("Skipping Overwriting...")
elif answer.isdigit():
ui.info(f"Overwriting {answer} blocks...")
runner.run(
[
"dd",
"if=/dev/zero",
f"of={device.path}",
f"bs={device.optimal_blocksize}",
f"count={answer}",
"status=progress",
],
sudo=True,
error_msg=f"Overwriting {device.path} failed.",
)
runner.sync_disks()
else:
raise LimError("Invalid input. Block count must be a number.")
def blkid_value(path: str, tag: str) -> str:
"""Return a blkid tag value ("TYPE", "UUID", ...) or "" when unavailable."""
return runner.output(
["blkid", path, "-s", tag, "-o", "value"], sudo=True, check=False
)
def is_mounted(path_fragment: str) -> bool:
"""Whether any current mount line contains the given fragment."""
mounts = runner.output(["mount"], check=False)
return any(path_fragment in line for line in mounts.splitlines())

2
lim/errors.py Normal file
View File

@@ -0,0 +1,2 @@
class LimError(Exception):
"""Fatal error that aborts the current lim command."""

32
lim/fsutil.py Normal file
View File

@@ -0,0 +1,32 @@
"""Small file manipulation helpers."""
from pathlib import Path
from lim import ui
from lim.errors import LimError
def replace_in_file(search: str, replace: str, path: str | Path) -> None:
"""Replace every literal occurrence of ``search``; fail when absent."""
path = Path(path)
text = path.read_text()
new_text = text.replace(search, replace)
if new_text == text:
raise LimError(f"Search string '{search}' not found in {path}.")
path.write_text(new_text)
def ensure_line_in_file(line: str, path: str | Path) -> bool:
"""Append ``line`` unless already present. Returns True when appended."""
path = Path(path)
content = path.read_text() if path.exists() else ""
if line in content.splitlines():
ui.warning(f"File {path} already contains the following entry:")
print(line)
ui.info("Skipped.")
return False
with path.open("a") as handle:
if content and not content.endswith("\n"):
handle.write("\n")
handle.write(line + "\n")
return True

0
lim/image/__init__.py Normal file
View File

32
lim/image/backup.py Normal file
View File

@@ -0,0 +1,32 @@
"""Create an image backup from a memory device into a file."""
from pathlib import Path
from lim import device as device_module
from lim import runner, ui
def run_backup() -> None:
ui.info("Backupscript for memory devices started...")
print()
device = device_module.select_device()
working_dir = Path.cwd()
path = ""
while not path:
path = ui.ask(f"Please type in backup image path+name relative to {working_dir}:")
output_file = f"{path}.img" if path.startswith("/") else f"{working_dir}/{path}.img"
ui.info(f"Input file: {device.path}")
ui.info(f"Output file: {output_file}")
ui.ask('Please confirm by pushing "Enter". To cancel use "Ctrl + C"')
ui.info("Imagetransfer starts. This can take a while...")
runner.run(
["dd", f"if={device.path}", f"of={output_file}", "bs=1M", "status=progress"],
sudo=True,
error_msg='"dd" failed.',
)
runner.sync_disks()
ui.success("Imagetransfer successfull.")

111
lim/image/choosers.py Normal file
View File

@@ -0,0 +1,111 @@
"""Interactive selection of the distribution image to install."""
from lim import catalog, runner, ui
from lim.errors import LimError
from lim.image.plan import ImagePlan
def choose_arch(plan: ImagePlan) -> None:
version = ui.ask("Which Raspberry Pi will be used (e.g.: 1, 2, 3b, 3b+, 4...):")
entry = catalog.arch_rpi_images().get(version)
if entry is None:
raise LimError(f"Version {version} isn't supported.")
plan.raspberry_pi_version = version
plan.boot_size = "+500M"
plan.base_download_url = "http://os.archlinuxarm.org/os/"
plan.image_name = entry["image"]
plan.luks_memory_cost = entry["luks_memory_cost"]
def choose_manjaro(plan: ImagePlan) -> None:
plan.boot_size = "+500M"
flavour = ui.ask("Which version(e.g.:architect,gnome) should be used:")
if flavour == "architect":
plan.image_checksum = "6b1c2fce12f244c1e32212767a9d3af2cf8263b2"
plan.base_download_url = (
"https://osdn.net/frs/redir.php?m=dotsrc&f=%2Fstorage%2Fg%2Fm%2Fma"
"%2Fmanjaro%2Farchitect%2F20.0%2F"
)
plan.image_name = "manjaro-architect-20.0-200426-linux56.iso"
elif flavour == "gnome":
release = ui.ask("Which release(e.g.:20,21,raspberrypi) should be used:")
entry = catalog.manjaro_gnome_releases().get(release)
if entry is None:
raise LimError(f"Gnome release {release} isn't supported.")
plan.image_checksum = entry.get("checksum")
plan.base_download_url = entry["url"]
plan.image_name = entry["image"]
plan.luks_memory_cost = entry.get("luks_memory_cost")
plan.raspberry_pi_version = entry.get("raspberry_pi_version")
else:
raise LimError(f"Manjaro version {flavour} isn't supported.")
def choose_moode(plan: ImagePlan) -> None:
plan.boot_size = "+200M"
plan.image_checksum = "185cbc9a4994534bb7a4bc2744c78197"
plan.base_download_url = (
"https://github.com/moode-player/moode/releases/download/r651prod/"
)
plan.image_name = "moode-r651-iso.zip"
def choose_retropie(plan: ImagePlan) -> None:
plan.boot_size = "+500M"
version = ui.ask("Which version(e.g.:1,2,3,4) should be used:")
entry = catalog.retropie_images().get(version)
if entry is None:
raise LimError(f"Version {version} isn't supported.")
plan.raspberry_pi_version = version
plan.base_download_url = (
"https://github.com/RetroPie/RetroPie-Setup/releases/download/4.8/"
)
plan.image_checksum = entry["checksum"]
plan.image_name = entry["image"]
def choose_torbox(plan: ImagePlan) -> None:
plan.base_download_url = "https://www.torbox.ch/data/"
plan.image_name = "torbox-20220102-v050.gz"
plan.image_checksum = (
"0E1BA7FFD14AAAE5F0462C8293D95B62C3BF1D9E726E26977BD04772C55680D3"
)
plan.boot_size = "+200M"
def choose_android_x86(plan: ImagePlan) -> None:
plan.base_download_url = (
"https://www.fosshub.com/Android-x86.html?dwl=android-x86_64-9.0-r2.iso"
)
plan.image_name = "android-x86_64-9.0-r2.iso"
plan.image_checksum = (
"f7eb8fc56f29ad5432335dc054183acf086c539f3990f0b6e9ff58bd6df4604e"
)
plan.boot_size = "+500M"
DISTRIBUTION_CHOOSERS = {
"android-x86": choose_android_x86,
"torbox": choose_torbox,
"arch": choose_arch,
"manjaro": choose_manjaro,
"moode": choose_moode,
"retropie": choose_retropie,
}
def choose_linux_image(plan: ImagePlan) -> None:
distribution = ui.ask(
"Which distribution should be used [arch,moode,retropie,manjaro,torbox...]?"
)
chooser = DISTRIBUTION_CHOOSERS.get(distribution)
if chooser is None:
raise LimError(f"Distribution {distribution} isn't supported.")
plan.distribution = distribution
chooser(plan)
def choose_local_image(plan: ImagePlan) -> None:
ui.info("Available images:")
runner.run(["ls", "-l", str(plan.image_folder)], check=False)
plan.image_name = ui.ask("Which image would you like to use?")

23
lim/image/chroot.py Normal file
View File

@@ -0,0 +1,23 @@
"""Mount an image and open an interactive shell inside it."""
from lim import device as device_module
from lim import runner, ui
from lim.image.session import ImageSession
def run_chroot() -> None:
ui.info("Starting chroot...")
device = device_module.select_device()
session = ImageSession(device)
try:
session.make_working_folder()
session.make_mount_folders()
session.decrypt_root()
session.mount_partitions()
session.mount_chroot_binds()
session.copy_qemu()
session.copy_resolv_conf()
ui.info("Bash shell starts...")
runner.run(["chroot", str(session.root_mount_path), "/bin/bash"], sudo=True)
finally:
session.destructor()

117
lim/image/encryption.py Normal file
View File

@@ -0,0 +1,117 @@
"""Remote-unlockable LUKS boot configuration (dropbear + mkinitcpio).
See https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38 and
https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994
"""
from pathlib import Path
from lim import catalog, fsutil, packages, runner, ui
from lim.image.plan import ImagePlan
from lim.image.session import ImageSession, chroot_bash, install_packages
MKINITCPIO_HOOKS_PREFIX = (
"base udev autodetect microcode modconf kms keyboard keymap consolefont block"
)
MKINITCPIO_HOOKS_SUFFIX = "filesystems fsck"
def _configure_mkinitcpio(plan: ImagePlan, root: Path) -> None:
# Concerning mkinitcpio warnings, see
# https://gist.github.com/imrvelj/c65cd5ca7f5505a65e59204f5a3f7a6d
mkinitcpio_path = root / "etc/mkinitcpio.conf"
ui.info(f"Configuring {mkinitcpio_path}...")
additional_modules = catalog.mkinitcpio_modules_by_rpi().get(
plan.raspberry_pi_version
)
if additional_modules is None:
ui.warning(f"Version {plan.raspberry_pi_version} isn't supported.")
additional_modules = ""
modules = f"g_cdc usb_f_acm usb_f_ecm {additional_modules} g_ether".replace(" ", " ")
fsutil.replace_in_file("MODULES=()", f"MODULES=({modules})", mkinitcpio_path)
fsutil.replace_in_file(
"BINARIES=()", "BINARIES=(/usr/lib/libgcc_s.so.1)", mkinitcpio_path
)
fsutil.replace_in_file(
f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} {MKINITCPIO_HOOKS_SUFFIX})",
f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} sleep netconf dropbear encryptssh "
f"{MKINITCPIO_HOOKS_SUFFIX})",
mkinitcpio_path,
)
ui.info(f"Content of {mkinitcpio_path}:{mkinitcpio_path.read_text()}")
ui.info("Generating mkinitcpio...")
chroot_bash(root, "mkinitcpio -vP")
def _register_encrypted_root(plan: ImagePlan, session: ImageSession, root: Path) -> None:
fstab_path = root / "etc/fstab"
fstab_line = (
f"UUID={session.root_partition_uuid} / {plan.root_filesystem}"
" defaults,noatime 0 1"
)
ui.info(f"Configuring {fstab_path}...")
fsutil.ensure_line_in_file(fstab_line, fstab_path)
ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}")
crypttab_path = root / "etc/crypttab"
crypttab_line = (
f"{session.root_mapper_name} UUID={session.root_partition_uuid} none luks"
)
ui.info(f"Configuring {crypttab_path}...")
fsutil.ensure_line_in_file(crypttab_line, crypttab_path)
ui.info(f"Content of {crypttab_path}:{crypttab_path.read_text()}")
def _configure_bootloader(plan: ImagePlan, session: ImageSession, root: Path) -> None:
cryptdevice = (
f"cryptdevice=UUID={session.root_partition_uuid}:{session.root_mapper_name} "
f"root={session.root_mapper_path}"
)
boot_txt_path = session.boot_mount_path / "boot.txt"
if boot_txt_path.is_file():
ui.info(f"Configuring {boot_txt_path}...")
hostname = (root / "etc/hostname").read_text().strip()
fsutil.replace_in_file(
"part uuid ${devtype} ${devnum}:2 uuid", "", boot_txt_path
)
fsutil.replace_in_file(
"setenv bootargs console=ttyS1,115200 console=tty0 root=PARTUUID=${uuid} "
'rw rootwait smsc95xx.macaddr="${usbethaddr}"',
f"setenv bootargs console=ttyS1,115200 console=tty0 "
f"ip=::::{hostname}:eth0:dhcp {cryptdevice} rw rootwait "
# Concerning issues with network adapter names, see
# https://forum.iobroker.net/topic/40542/raspberry-pi4-kein-eth0-mehr/16
'smsc95xx.macaddr="${usbethaddr}" net.ifnames=0 biosdevname=0',
boot_txt_path,
)
ui.info(f"Content of {boot_txt_path}:{boot_txt_path.read_text()}")
ui.info("Generating...")
chroot_bash(root, "cd /boot/ && ./mkscr || exit 1")
else:
cmdline_txt_path = session.boot_mount_path / "cmdline.txt"
ui.info(f"Configuring {cmdline_txt_path}...")
fsutil.replace_in_file(
"root=/dev/mmcblk0p2",
f"{cryptdevice} rootfstype={plan.root_filesystem}",
cmdline_txt_path,
)
ui.info(f"Content of {cmdline_txt_path}:{cmdline_txt_path.read_text()}")
def configure_encryption(
plan: ImagePlan, session: ImageSession, authorized_keys: Path
) -> None:
root = session.root_mount_path
ui.info("Setup encryption...")
ui.info("Installing neccessary software...")
install_packages(
plan.distribution, root, " ".join(packages.get_packages("server/luks"))
)
dropbear_root_key_path = root / "etc/dropbear/root_key"
ui.info(f"Adding {authorized_keys} to dropbear...")
runner.run(["cp", "-v", str(authorized_keys), str(dropbear_root_key_path)], sudo=True)
_configure_mkinitcpio(plan, root)
_register_encrypted_root(plan, session, root)
_configure_bootloader(plan, session, root)

31
lim/image/plan.py Normal file
View File

@@ -0,0 +1,31 @@
"""The image setup plan collected from the user's answers."""
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_BOOT_SIZE = "+500M"
@dataclass
class ImagePlan:
operation_system: str = "linux"
distribution: str | None = None
base_download_url: str | None = None
image_name: str | None = None
image_checksum: str | None = None
boot_size: str = ""
luks_memory_cost: str | None = None
raspberry_pi_version: str | None = None
encrypt_system: bool = False
root_filesystem: str = ""
image_folder: Path = field(default_factory=Path)
@property
def download_url(self) -> str | None:
if self.base_download_url is None or self.image_name is None:
return None
return f"{self.base_download_url}{self.image_name}"
@property
def image_path(self) -> Path:
return self.image_folder / self.image_name

236
lim/image/raspberry.py Normal file
View File

@@ -0,0 +1,236 @@
"""Raspberry-Pi-specific configuration of a freshly transferred image."""
from pathlib import Path
from lim import device as device_module
from lim import fsutil, runner, ui
from lim.errors import LimError
from lim.image.encryption import configure_encryption
from lim.image.plan import ImagePlan
from lim.image.session import ImageSession, chroot_bash, install_packages
ADMINISTRATOR_USERNAME = "administrator"
def configure_sudoers(root_mount_path: Path, username: str) -> None:
sudo_config_dir = root_mount_path / "etc/sudoers.d"
sudo_config_file = sudo_config_dir / username
sudo_config_dir.mkdir(parents=True, exist_ok=True)
try:
sudo_config_file.write_text(f"{username} ALL=(ALL:ALL) ALL\n")
sudo_config_file.chmod(0o440)
except OSError as exc:
raise LimError(f"Failed to create sudoers file for {username}: {exc}") from exc
def configure_ssh_key(
public_key_path: str, target_ssh_folder: Path, authorized_keys: Path
) -> None:
source = Path(public_key_path)
if not source.is_file():
raise LimError(
f'The ssh key "{public_key_path}" can\'t be copied to '
f'"{authorized_keys}" because it doesn\'t exist.'
)
ui.info("Copy ssh key to target...")
target_ssh_folder.mkdir(parents=True, exist_ok=True)
authorized_keys.write_text(source.read_text())
ui.info(f"{authorized_keys} contains the following: {authorized_keys.read_text()}")
ui.info("Set permissions with chmod...")
target_ssh_folder.chmod(0o700)
authorized_keys.chmod(0o600)
def _ensure_image_mounted(session: ImageSession) -> None:
ui.info("Start regular mounting procedure...")
if device_module.is_mounted(session.boot_partition_path):
ui.info(f"{session.boot_partition_path} is allready mounted...")
elif device_module.is_mounted(session.root_mapper_path):
ui.info(f"{session.root_mapper_path} is allready mounted...")
else:
session.decrypt_root()
session.mount_partitions()
if session.boot_partition_uuid is None:
# Partitions were mounted by an earlier run; fetch what mount_partitions
# would have provided so fstab/crypttab never see a None UUID.
session.read_partition_uuids()
def _seed_boot_uuid(session: ImageSession) -> None:
fstab_path = session.root_mount_path / "etc/fstab"
if "/dev/mmcblk0p1" not in fstab_path.read_text():
# PARTUUID=/LABEL= based images (e.g. RetroPie, Manjaro ARM) have
# nothing to seed — that is a valid state, not an error.
ui.info(f"{fstab_path} does not reference /dev/mmcblk0p1. Skipping UUID seeding.")
return
ui.info(f"Seeding UUID to {fstab_path} to avoid path conflicts...")
fsutil.replace_in_file(
"/dev/mmcblk0p1", f"UUID={session.boot_partition_uuid}", fstab_path
)
ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}")
def _select_target_user(root: Path) -> tuple[str, str]:
"""Return (default username, target username), renaming the home on request."""
target_home_path = root / "home"
home_entries = sorted(path.name for path in target_home_path.iterdir())
if not home_entries:
raise LimError(f"No home directory found under {target_home_path}.")
default_username = home_entries[0]
rename = default_username != ADMINISTRATOR_USERNAME and ui.confirm(
f"Should the {default_username} be renamed to {ADMINISTRATOR_USERNAME}? "
)
if not rename:
return default_username, default_username
ui.info(
f"Rename home directory from {target_home_path / default_username} "
f"to {target_home_path / ADMINISTRATOR_USERNAME}..."
)
runner.run(
[
"mv",
"-v",
str(target_home_path / default_username),
str(target_home_path / ADMINISTRATOR_USERNAME),
],
sudo=True,
error_msg="Failed to rename home directory",
)
return default_username, ADMINISTRATOR_USERNAME
def _change_passwords(root: Path, target_username: str) -> None:
password = ui.ask(
f"Type in new password for user root and {target_username} (leave empty to skip): "
)
if not password:
ui.info("No password change requested, skipped password change...")
return
repeated = ui.ask(f'Repeat new password for "{target_username}": ')
if password != repeated:
raise LimError("Passwords didn't match.")
ui.info("Changing passwords on target system...")
chroot_bash(
root,
f"( echo '{password}'; echo '{password}' ) | passwd {target_username}\n"
f"( echo '{password}'; echo '{password}' ) | passwd\n",
error_msg="Failed to change password.",
)
def _configure_hostname(root: Path) -> None:
hostname_path = root / "etc/hostname"
hostname = ui.ask("Type in the hostname (leave empty to skip): ")
if hostname:
hostname_path.write_text(hostname + "\n")
else:
hostname = hostname_path.read_text().strip()
ui.info("No hostname change requested, skipped hostname change...")
ui.info(f"Used hostname is: {hostname}")
def _update_system(plan: ImagePlan, root: Path) -> None:
if not ui.confirm("Should the system be updated?"):
return
ui.info("Updating system...")
if plan.distribution in ("arch", "manjaro"):
chroot_bash(root, "pacman --noconfirm -Syyu")
elif plan.distribution in ("moode", "retropie"):
chroot_bash(root, "yes | apt update\nyes | apt upgrade\n")
else:
ui.warning(
f'System update for operation system "{plan.distribution}" '
"is not supported yet. Skipped."
)
def _run_retropie_procedures(session: ImageSession, public_key_path: str) -> None:
if public_key_path:
(session.boot_mount_path / "ssh").write_text("\n")
if not ui.confirm("Should the RetroFlag specific procedures be executed?"):
return
ui.info("Executing RetroFlag specific procedures...")
chroot_bash(
session.root_mount_path,
'wget -O - "https://raw.githubusercontent.com/RetroFlag/'
'retroflag-picase/master/install_gpi.sh" | bash\n',
)
def _configure_users_and_keys(session: ImageSession) -> tuple[str, Path]:
"""Handle user rename, sudo rights and SSH key; return (public key path, authorized_keys)."""
root = session.root_mount_path
ui.info("Define target paths...")
default_username, target_username = _select_target_user(root)
renamed = default_username != target_username
target_user_ssh_folder = root / "home" / target_username / ".ssh"
target_authorized_keys = target_user_ssh_folder / "authorized_keys"
if ui.confirm(f"Should the {target_username} have sudo rights? "):
configure_sudoers(root, target_username)
public_key_path = ui.ask(
"Enter the path to the SSH key to be added to the image (default: none):"
)
if public_key_path:
configure_ssh_key(public_key_path, target_user_ssh_folder, target_authorized_keys)
else:
ui.info("Skipped SSH-key copying..")
ui.info("Start chroot procedures...")
session.mount_chroot_binds()
session.copy_qemu()
session.copy_resolv_conf()
chroot_user_home = f"/home/{target_username}/"
if renamed:
ui.info("Delete old user and create new user")
chroot_bash(
root,
f"userdel -r {default_username}\n"
f"useradd -m -d {chroot_user_home} -s /bin/bash {target_username}\n"
f"chown -R {target_username}:{target_username} {chroot_user_home}\n",
error_msg="Failed to delete old user and create new user",
)
if public_key_path:
ui.info("Chroot to set ownership...")
chroot_bash(
root,
f"chown -vR {target_username}:{target_username} {chroot_user_home}.ssh\n",
)
_change_passwords(root, target_username)
return public_key_path, target_authorized_keys
def configure_raspberry_image(plan: ImagePlan, session: ImageSession) -> None:
_ensure_image_mounted(session)
root = session.root_mount_path
_seed_boot_uuid(session)
public_key_path, target_authorized_keys = _configure_users_and_keys(session)
_configure_hostname(root)
if plan.distribution in ("arch", "manjaro"):
ui.info("Populating keys...")
chroot_bash(
root,
"yes | pacman-key --init\nyes | pacman-key --populate archlinuxarm\n",
)
_update_system(plan, root)
ui.info(f"Installing software for filesystem {plan.root_filesystem}...")
if plan.root_filesystem == "btrfs":
install_packages(plan.distribution, root, "btrfs-progs")
else:
ui.info("Skipped.")
if plan.encrypt_system:
configure_encryption(plan, session, target_authorized_keys)
ui.info("Running system specific procedures...")
if plan.distribution == "retropie":
_run_retropie_procedures(session, public_key_path)

190
lim/image/session.py Normal file
View File

@@ -0,0 +1,190 @@
"""Mount/unmount lifecycle for working on an image on a block device."""
import time
from pathlib import Path
from lim import device as device_module
from lim import runner, ui
from lim.device import Device
from lim.errors import LimError
class ImageSession:
"""Working folder, partition paths and (chroot) mounts for one device.
Call ``destructor()`` in a finally block: it unmounts and removes
everything best-effort, mirroring partially completed setups.
"""
def __init__(self, device: Device) -> None:
self.device = device
self.working_folder: Path | None = None
self.boot_mount_path: Path | None = None
self.root_mount_path: Path | None = None
self.boot_partition_path = device.partition(1)
self.root_partition_path = device.partition(2)
# Overridden by decrypt_root() when the root partition is LUKS.
self.root_mapper_path = self.root_partition_path
self.root_mapper_name: str | None = None
self.boot_partition_uuid: str | None = None
self.root_partition_uuid: str | None = None
def make_working_folder(self) -> None:
# Mount points must live on the root fs; mkdir() fails on collisions.
self.working_folder = Path(f"/tmp/linux-image-manager-{int(time.time())}") # noqa: S108
ui.info(f"Create temporary working folder in {self.working_folder}")
self.working_folder.mkdir(parents=True)
def make_mount_folders(self) -> None:
if self.working_folder is None:
self.make_working_folder()
ui.info("Preparing mount paths...")
self.boot_mount_path = self.working_folder / "boot"
self.root_mount_path = self.working_folder / "root"
self.boot_mount_path.mkdir()
self.root_mount_path.mkdir()
def root_is_luks(self) -> bool:
return device_module.blkid_value(self.root_partition_path, "TYPE") == "crypto_LUKS"
def read_partition_uuids(self) -> None:
"""Fetch partition UUIDs via blkid; derive the LUKS mapper name if unset."""
self.root_partition_uuid = device_module.blkid_value(
self.root_partition_path, "UUID"
)
self.boot_partition_uuid = device_module.blkid_value(
self.boot_partition_path, "UUID"
)
if self.root_mapper_name is None and self.root_is_luks():
# Same deterministic name decrypt_root() would have used.
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
def decrypt_root(self) -> None:
if not self.root_is_luks():
return
self.root_partition_uuid = device_module.blkid_value(
self.root_partition_path, "UUID"
)
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
ui.info(f"Decrypting of {self.root_partition_path} is neccessary...")
runner.run(
[
"cryptsetup",
"-v",
"luksOpen",
self.root_partition_path,
self.root_mapper_name,
],
sudo=True,
)
def mount_partitions(self) -> None:
if self.boot_mount_path is None:
raise LimError("Mount folders are not prepared yet.")
ui.info("Mount boot and root partition...")
runner.run(
["mount", "-v", self.boot_partition_path, str(self.boot_mount_path)],
sudo=True,
)
runner.run(
["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True
)
ui.info("Setting uuid variables...")
self.read_partition_uuids()
ui.info("The following mounts refering this setup exist:")
runner.run(["findmnt", "-R", str(self.working_folder)], check=False)
def mount_chroot_binds(self) -> None:
ui.info("Mount chroot environments...")
root = self.root_mount_path
runner.run(["mount", "--bind", str(self.boot_mount_path), f"{root}/boot"], sudo=True)
runner.run(["mount", "--bind", "/dev", f"{root}/dev"], sudo=True)
runner.run(["mount", "--bind", "/sys", f"{root}/sys"], sudo=True)
runner.run(["mount", "--bind", "/proc", f"{root}/proc"], sudo=True)
runner.run(["mount", "--bind", "/dev/pts", f"{root}/dev/pts"], sudo=True)
def copy_qemu(self) -> None:
ui.info("Copy qemu binary...")
runner.run(
["cp", "-v", "/usr/bin/qemu-arm-static", f"{self.root_mount_path}/usr/bin/"],
sudo=True,
)
def copy_resolv_conf(self) -> None:
ui.info("Copy resolv.conf...")
copied = runner.run(
[
"cp",
"--remove-destination",
"-v",
"/etc/resolv.conf",
f"{self.root_mount_path}/etc/",
],
sudo=True,
check=False,
)
if copied.returncode != 0:
ui.warning("Failed. Probably there is no internet connection available.")
def _umount(self, path: str, *, lazy: bool = False) -> None:
flags = ["-lv"] if lazy else ["-v"]
result = runner.run(["umount", *flags, path], sudo=True, check=False)
if result.returncode != 0:
ui.warning(f"Umounting {path} failed!")
def _rmdir(self, path: Path | None) -> None:
if path is None:
return
result = runner.run(["rmdir", "-v", str(path)], sudo=True, check=False)
if result.returncode != 0:
ui.warning(f"Removing {path} failed!")
def destructor(self) -> None:
ui.info("Cleaning up...")
ui.info("Unmounting everything...")
root = self.root_mount_path
if root is not None:
self._umount(f"{root}/dev/pts", lazy=True)
self._umount(f"{root}/dev", lazy=True)
self._umount(f"{root}/proc")
self._umount(f"{root}/sys")
self._umount(f"{root}/boot")
self._umount(str(root))
if self.boot_mount_path is not None:
self._umount(str(self.boot_mount_path))
ui.info("Deleting mount folders...")
self._rmdir(self.root_mount_path)
self._rmdir(self.boot_mount_path)
self._rmdir(self.working_folder)
if self.root_mapper_name and self.root_is_luks():
ui.info(f"Trying to close decrypted {self.root_mapper_name}...")
closed = runner.run(
["cryptsetup", "-v", "luksClose", self.root_mapper_name],
sudo=True,
check=False,
)
if closed.returncode != 0:
ui.warning("Failed.")
def chroot_bash(root_mount_path: Path | str, script: str, error_msg: str | None = None) -> None:
"""Run a bash script inside the image via chroot."""
runner.run(
["chroot", str(root_mount_path), "/bin/bash"],
input_text=script,
sudo=True,
error_msg=error_msg,
)
def install_packages(distribution: str, root_mount_path: Path, package_names: str) -> None:
"""Install packages inside the image with the distribution's package manager."""
ui.info(f"Installing {package_names}...")
if distribution in ("arch", "manjaro"):
chroot_bash(root_mount_path, f"pacman --noconfirm -S --needed {package_names}")
elif distribution in ("moode", "retropie"):
chroot_bash(root_mount_path, f"yes | apt install {package_names}")
else:
raise LimError("Package manager not supported.")

96
lim/image/setup.py Normal file
View File

@@ -0,0 +1,96 @@
"""Interactive Linux image setup: download, verify, transfer, configure.
Reference for encrypted Raspberry Pi images:
https://wiki.polaire.nl/doku.php?id=archlinux-raspberry-encrypted
"""
import pwd
from pathlib import Path
from lim import device as device_module
from lim import system, ui
from lim.errors import LimError
from lim.image import choosers, transfer, verify
from lim.image.plan import ImagePlan
from lim.image.raspberry import configure_raspberry_image
from lim.image.session import ImageSession
def _prepare_image_folder(plan: ImagePlan) -> None:
ui.info("Configure user...")
origin_username = ui.ask("Please type in a valid working username:")
try:
pwd.getpwnam(origin_username)
except KeyError:
raise LimError(f"User {origin_username} doesn't exist.") from None
ui.info("Image routine starts...")
plan.image_folder = Path(f"/home/{origin_username}/Software/Images")
ui.info(f'The images will be stored in "{plan.image_folder}".')
if not plan.image_folder.is_dir():
ui.info(f'Folder "{plan.image_folder}" doesn\'t exist. It will be created now.')
plan.image_folder.mkdir(parents=True)
def _select_unmounted_device() -> device_module.Device:
device = device_module.select_device()
if device_module.is_mounted(device.path):
raise LimError(
f'Device {device.path} is allready mounted. '
f'Umount with "umount {device.path}*".'
)
return device
def _choose_and_verify_image(plan: ImagePlan) -> None:
plan.operation_system = ui.ask(
"Which operation system would you like to use [linux,windows,...]?"
)
if plan.operation_system == "linux":
choosers.choose_linux_image(plan)
plan.encrypt_system = ui.confirm("Should the system be encrypted?")
ui.info("Generating os-image...")
transfer.download_image(plan)
else:
choosers.choose_local_image(plan)
ui.info("Verifying image...")
ui.info("Verifying checksum...")
if plan.image_checksum is None and plan.download_url is not None:
plan.image_checksum = verify.resolve_checksum(plan.download_url)
verify.verify_checksum(plan.image_path, plan.image_checksum)
if plan.download_url is not None:
verify.verify_signature(plan.download_url, plan.image_path, plan.image_folder)
def run_setup() -> None:
ui.info("Setupscript for images started...")
ui.info("Checking if root...")
if not system.is_root():
raise LimError("This script must be executed as root!")
plan = ImagePlan()
_prepare_image_folder(plan)
device = _select_unmounted_device()
_choose_and_verify_image(plan)
session = ImageSession(device)
try:
session.make_working_folder()
session.make_mount_folders()
plan.root_filesystem = ui.ask(
"Which filesystem should be used? E.g.:btrfs,ext4... (none):"
)
if ui.confirm(f"Should the image be transfered to {device.path}?"):
transfer.transfer_image(plan, session)
else:
ui.info("Skipping image transfer...")
if plan.raspberry_pi_version:
configure_raspberry_image(plan, session)
finally:
session.destructor()
ui.success("Setup successfull :)")

140
lim/image/transfer.py Normal file
View File

@@ -0,0 +1,140 @@
"""Download the selected image and transfer it onto the target device."""
from pathlib import Path
from lim import device as device_module
from lim import runner, ui
from lim.errors import LimError
from lim.image.plan import DEFAULT_BOOT_SIZE, ImagePlan
from lim.image.session import ImageSession
def download_image(plan: ImagePlan) -> None:
if ui.confirm("Should the image download be forced?"):
if plan.image_path.is_file():
ui.info(f"Removing image {plan.image_path}.")
plan.image_path.unlink()
else:
ui.info(
"Forcing download wasn't neccessary. "
f"File {plan.image_path} doesn't exist."
)
ui.info("Start Download procedure...")
if plan.image_path.is_file():
ui.info("Image exist local. Download skipped.")
return
ui.info(f'Image "{plan.image_name}" doesn\'t exist under local path "{plan.image_path}".')
ui.info(f'Image "{plan.image_name}" gets downloaded from "{plan.download_url}"...')
runner.run(
["wget", plan.download_url, "-O", str(plan.image_path)],
error_msg=f'Download from "{plan.download_url}" failed.',
)
def arch_partition_input(boot_size: str) -> str:
"""Fdisk answers: FAT32 boot partition of boot_size plus root partition."""
return (
"o\n" # clear out any partitions on the drive
"p\n" # list partitions (should be empty)
"n\np\n1\n\n" # new primary partition 1, default start sector
f"{boot_size}\n"
"t\nc\n" # set partition 1 to type W95 FAT32 (LBA)
"n\np\n2\n\n\n" # new primary partition 2 over the remaining space
"w\n" # write partition table
)
def decompress_command(image_path: Path) -> list[str]:
"""Build the command that streams the raw image to stdout for dd."""
suffix = image_path.suffix
if suffix == ".zip":
return ["unzip", "-p", str(image_path)]
if suffix == ".gz":
return ["gunzip", "-c", str(image_path)]
if suffix == ".iso":
return ["pv", str(image_path)]
if suffix == ".xz":
return ["unxz", "-c", str(image_path)]
raise LimError(f'Image transfer for "{image_path.name}" is not supported yet!')
def _luks_format_root(plan: ImagePlan, session: ImageSession) -> None:
luks_format = [
"cryptsetup", "-v", "luksFormat",
"-c", "aes-xts-plain64", "-s", "512", "-h", "sha512",
"--use-random", "-i", "1000",
]
if plan.luks_memory_cost:
ui.info(
f"Formating {session.root_partition_path} with LUKS "
f"with --pbkdf-memory set to {plan.luks_memory_cost}"
)
luks_format += ["--pbkdf-memory", plan.luks_memory_cost]
else:
ui.info(f"Formating {session.root_partition_path} with LUKS")
runner.run([*luks_format, session.root_partition_path], sudo=True)
def transfer_arch_image(plan: ImagePlan, session: ImageSession) -> None:
boot_size = plan.boot_size or DEFAULT_BOOT_SIZE
ui.info(f"The boot partition will be set to {boot_size}.")
ui.info("Creating partitions...")
runner.run(
["fdisk", session.device.path],
input_text=arch_partition_input(boot_size),
sudo=True,
)
ui.info("Format boot partition...")
runner.run(["mkfs.vfat", session.boot_partition_path], sudo=True)
if plan.encrypt_system:
_luks_format_root(plan, session)
session.decrypt_root()
ui.info("Format root partition...")
runner.run([f"mkfs.{plan.root_filesystem}", "-f", session.root_mapper_path], sudo=True)
session.mount_partitions()
ui.info("Root files will be transfered to device...")
runner.run(
["bsdtar", "-xpf", str(plan.image_path), "-C", str(session.root_mount_path)],
sudo=True,
)
runner.sync_disks()
ui.info("Boot files will be transfered to device...")
boot_source = session.root_mount_path / "boot"
for entry in sorted(boot_source.iterdir()):
runner.run(["mv", "-v", str(entry), str(session.boot_mount_path)], sudo=True)
def transfer_image(plan: ImagePlan, session: ImageSession) -> None:
if ui.confirm(f"Should the partition table of {session.device.path} be deleted?"):
ui.info("Deleting...")
runner.run(["wipefs", "-a", session.device.path], sudo=True)
else:
ui.info("Skipping partition table deletion...")
device_module.overwrite_device(session.device)
ui.info("Starting image transfer...")
if plan.distribution == "arch":
transfer_arch_image(plan, session)
return
blocksize = session.device.optimal_blocksize
ui.info(f"Transfering {plan.image_path.suffix} file...")
runner.pipeline(
decompress_command(plan.image_path),
[
"dd",
f"of={session.device.path}",
f"bs={blocksize}",
"conv=fsync",
"status=progress",
],
sudo_last=True,
error_msg=f"DD {plan.image_path} to {session.device.path} failed.",
)
runner.sync_disks()

100
lim/image/verify.py Normal file
View File

@@ -0,0 +1,100 @@
"""Image integrity (checksums) and authenticity (GPG signatures) checks."""
import hashlib
import re
from pathlib import Path
from lim import runner, ui
from lim.errors import LimError
ALGORITHM_BY_DIGEST_LENGTH = {
32: "md5",
40: "sha1",
64: "sha256",
128: "sha512",
}
def url_exists(url: str) -> bool:
return runner.succeeds(["wget", "-q", "--method=HEAD", url])
def resolve_checksum(download_url: str) -> str | None:
"""Try to fetch a published checksum next to the image download."""
for extension in ("sha1", "sha512", "md5"):
checksum_url = f"{download_url}.{extension}"
ui.info(
"Image Checksum is not defined. "
f"Try to download image signature from {checksum_url}."
)
if url_exists(checksum_url):
content = runner.output(["wget", checksum_url, "-q", "-O", "-"])
checksum = content.split()[0] if content.split() else ""
if checksum:
ui.info(f"Defined image_checksum as {checksum}")
return checksum
ui.warning(f"No checksum found under {checksum_url}.")
return None
def verify_checksum(image_path: str | Path, checksum: str | None) -> None:
"""Verify the image against an md5/sha1/sha256/sha512 hex checksum."""
if not checksum:
ui.warning("No checksum is defined. Skipping checksum verification.")
return
algorithm = ALGORITHM_BY_DIGEST_LENGTH.get(len(checksum))
if algorithm is None:
raise LimError(
f"Checksum '{checksum}' has no recognized digest length "
"(expected md5, sha1, sha256 or sha512)."
)
ui.info(f"Checking {algorithm} checksum...")
digest = hashlib.new(algorithm)
with Path(image_path).open("rb") as handle:
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
if digest.hexdigest() != checksum.lower():
raise LimError("Verification failed. HINT: Force the download of the image.")
ui.info(f"{algorithm} checksum verified.")
def verify_signature(download_url: str, image_path: str | Path, image_folder: Path) -> None:
"""Best-effort GPG signature verification of the downloaded image."""
ui.info("Note: Checksums verify integrity but do not confirm authenticity.")
ui.info(
"Proceeding to signature verification, "
"which ensures the file comes from a trusted source."
)
signature_url = f"{download_url}.sig"
ui.info(f"Attempting to download the image signature from: {signature_url}")
if not url_exists(signature_url):
ui.warning(f"No signature found under {signature_url}.")
return
signature_path = image_folder / (Path(str(image_path)).name + ".sig")
if not runner.succeeds(["wget", "-q", "-O", str(signature_path), signature_url]):
ui.warning("Failed to download the signature file.")
return
ui.info("Extract the key ID from the signature file")
verification = runner.output(
["gpg", "--status-fd", "1", "--verify", str(signature_path), str(image_path)],
check=False,
)
missing_keys = re.findall(r"NO_PUBKEY (\S+)", verification)
if missing_keys:
key_id = missing_keys[-1]
if runner.succeeds(["gpg", "--list-keys", key_id]):
ui.info(f"Key {key_id} already in keyring.")
else:
ui.info("Import the public key")
runner.run(
["gpg", "--keyserver", "keyserver.ubuntu.com", "--recv-keys", key_id],
check=False,
)
ui.info("Verify the signature")
if runner.succeeds(["gpg", "--verify", str(signature_path), str(image_path)]):
ui.info("Signature verification succeeded.")
else:
ui.warning("Signature verification failed.")

80
lim/luks.py Normal file
View File

@@ -0,0 +1,80 @@
"""LUKS key management plus crypttab/fstab bookkeeping."""
import re
from pathlib import Path
from lim import fsutil, runner, ui
from lim.errors import LimError
LUKS_KEY_DIRECTORY = Path("/etc/luks-keys")
CRYPTTAB_PATH = Path("/etc/crypttab")
FSTAB_PATH = Path("/etc/fstab")
def luks_uuid(partition_path: str) -> str:
dump = runner.output(["cryptsetup", "luksDump", partition_path], sudo=True)
match = re.search(r"UUID:\s*(\S+)", dump)
if not match:
raise LimError(f"Could not read LUKS UUID of {partition_path}.")
return match.group(1)
def create_luks_key_and_update_crypttab(
mapper_name: str,
partition_path: str,
*,
key_directory: Path = LUKS_KEY_DIRECTORY,
crypttab_path: Path = CRYPTTAB_PATH,
) -> None:
"""Generate a random keyfile, register it with LUKS and /etc/crypttab."""
ui.info("Creating luks-key-directory...")
key_directory.mkdir(parents=True, exist_ok=True)
secret_key_path = key_directory / f"{mapper_name}.keyfile"
ui.info(f"Generate secret key under: {secret_key_path}")
if secret_key_path.exists():
ui.warning("File already exists. Overwriting!")
runner.run(
["dd", "if=/dev/urandom", f"of={secret_key_path}", "bs=512", "count=8"],
sudo=True,
)
runner.sync_disks()
ui.info("Opening and closing device to verify that everything works fine...")
closed = runner.run(
["cryptsetup", "-v", "luksClose", mapper_name], sudo=True, check=False
)
if closed.returncode != 0:
ui.info(f"No need to luksClose {mapper_name}. Device isn't open.")
runner.run(
["cryptsetup", "luksAddKey", partition_path, str(secret_key_path)], sudo=True
)
runner.run(
[
"cryptsetup",
"-v",
"luksOpen",
partition_path,
mapper_name,
f"--key-file={secret_key_path}",
],
sudo=True,
)
runner.run(["cryptsetup", "-v", "luksClose", mapper_name], sudo=True)
ui.info("Reading UUID...")
uuid = luks_uuid(partition_path)
entry = f"{mapper_name} UUID={uuid} {secret_key_path} luks"
ui.info("Adding crypttab entry...")
fsutil.ensure_line_in_file(entry, crypttab_path)
ui.info(f"The file {crypttab_path} contains now the following:")
print(crypttab_path.read_text())
def update_fstab(
mapper_path: str, mount_path: str, *, fstab_path: Path = FSTAB_PATH
) -> None:
entry = f"{mapper_path} {mount_path} btrfs defaults 0 2"
ui.info("Adding fstab entry...")
fsutil.ensure_line_in_file(entry, fstab_path)
ui.info(f"The file {fstab_path} contains now the following:")
print(fstab_path.read_text())

18
lim/packages.py Normal file
View File

@@ -0,0 +1,18 @@
"""Reads package collections from configuration/packages/."""
from lim import config
from lim.errors import LimError
def get_packages(*collections: str) -> list[str]:
"""Package names from the given collections, comments stripped."""
names: list[str] = []
for collection in collections:
path = config.PACKAGE_PATH / f"{collection}.txt"
if not path.is_file():
raise LimError(f"Package collection {path} does not exist.")
for line in path.read_text().splitlines():
name = line.split("#", 1)[0].strip()
if name:
names.append(name)
return names

134
lim/runner.py Normal file
View File

@@ -0,0 +1,134 @@
"""Thin wrapper around subprocess for all external commands.
Every module calls external tools through this module so tests can
replace ``run``/``output``/``succeeds``/``pipeline`` with fakes.
Missing binaries never surface as tracebacks: actions (``check=True``,
``pipeline``) abort with a clear LimError, tolerated calls
(``check=False``, ``succeeds``) degrade to a warning plus shell-style
exit code 127.
"""
import os
import shlex
import subprocess
from lim import ui
from lim.errors import LimError
COMMAND_NOT_FOUND = 127
def _with_sudo(cmd: list[str], *, sudo: bool) -> list[str]:
parts = [str(part) for part in cmd]
if sudo and os.geteuid() != 0:
return ["sudo", *parts]
return parts
def _not_found_message(cmd: list[str]) -> str:
return f"Command not found: {cmd[0]} — please install it."
def run(
cmd: list[str],
*,
sudo: bool = False,
input_text: str | None = None,
check: bool = True,
error_msg: str | None = None,
) -> subprocess.CompletedProcess:
"""Run a command inheriting stdio so progress output stays visible."""
cmd = _with_sudo(cmd, sudo=sudo)
ui.info(f"Running: {shlex.join(cmd)}")
try:
result = subprocess.run(cmd, input=input_text, text=True, check=False)
except FileNotFoundError:
if check:
raise LimError(error_msg or _not_found_message(cmd)) from None
ui.warning(_not_found_message(cmd))
return subprocess.CompletedProcess(cmd, COMMAND_NOT_FOUND)
if check and result.returncode != 0:
raise LimError(
error_msg
or f"Command failed with code {result.returncode}: {shlex.join(cmd)}"
)
return result
def output(
cmd: list[str],
*,
sudo: bool = False,
check: bool = True,
error_msg: str | None = None,
) -> str:
"""Run a command and return its stripped stdout."""
cmd = _with_sudo(cmd, sudo=sudo)
try:
result = subprocess.run(cmd, text=True, capture_output=True, check=False)
except FileNotFoundError:
if check:
raise LimError(error_msg or _not_found_message(cmd)) from None
ui.warning(_not_found_message(cmd))
return ""
if check and result.returncode != 0:
raise LimError(
error_msg
or f"Command failed with code {result.returncode}: {shlex.join(cmd)}"
+ (f"\n{result.stderr.strip()}" if result.stderr else "")
)
return result.stdout.strip()
def succeeds(cmd: list[str], *, sudo: bool = False) -> bool:
"""Run a command silently, report whether it exited with 0."""
cmd = _with_sudo(cmd, sudo=sudo)
try:
return subprocess.run(cmd, capture_output=True, check=False).returncode == 0
except FileNotFoundError:
ui.warning(_not_found_message(cmd))
return False
def pipeline(
*cmds: list[str],
sudo_last: bool = False,
error_msg: str | None = None,
) -> None:
"""Run commands as a shell-style pipeline (cmd1 | cmd2 | ...)."""
prepared = [
_with_sudo(cmd, sudo=sudo_last and index == len(cmds) - 1)
for index, cmd in enumerate(cmds)
]
pretty = " | ".join(shlex.join(cmd) for cmd in prepared)
ui.info(f"Running: {pretty}")
processes: list[subprocess.Popen] = []
previous_stdout = None
try:
for index, cmd in enumerate(prepared):
last = index == len(prepared) - 1
process = subprocess.Popen(
cmd,
stdin=previous_stdout,
stdout=None if last else subprocess.PIPE,
)
if previous_stdout is not None:
previous_stdout.close()
previous_stdout = process.stdout
processes.append(process)
except FileNotFoundError as exc:
if previous_stdout is not None:
previous_stdout.close()
for process in processes:
process.kill()
process.wait()
raise LimError(f"Command not found: {exc.filename} — please install it.") from None
for process in processes:
process.wait()
if any(process.returncode != 0 for process in processes):
raise LimError(error_msg or f"Pipeline failed: {pretty}")
def sync_disks() -> None:
run(["sync"])

0
lim/storage/__init__.py Normal file
View File

38
lim/storage/common.py Normal file
View File

@@ -0,0 +1,38 @@
"""Shared path derivation for encrypted storage setups."""
from dataclasses import dataclass
from lim import device as device_module
from lim import ui
from lim.device import Device
@dataclass(frozen=True)
class StorageTarget:
"""An encrypted drive plus all derived mapper/mount/partition paths."""
device: Device
@property
def mapper_name(self) -> str:
return f"encrypteddrive-{self.device.name}"
@property
def mapper_path(self) -> str:
return f"/dev/mapper/{self.mapper_name}"
@property
def mount_path(self) -> str:
return f"/media/{self.mapper_name}"
@property
def partition_path(self) -> str:
return self.device.partition(1)
def select_storage_target() -> StorageTarget:
target = StorageTarget(device_module.select_device())
ui.info(f"mapper name set to : {target.mapper_name}")
ui.info(f"mapper path set to : {target.mapper_path}")
ui.info(f"mount path set to : {target.mount_path}")
return target

68
lim/storage/raid1.py Normal file
View File

@@ -0,0 +1,68 @@
"""Encrypted Btrfs RAID1 across two drives.
See https://balaskas.gr/btrfs/raid1.html and
https://mutschler.eu/linux/install-guides/ubuntu-btrfs-raid1/
"""
from lim import luks, runner, ui
from lim.storage.common import StorageTarget, select_storage_target
def _select_pair() -> tuple[StorageTarget, StorageTarget]:
ui.info("RAID1 partition 1...")
first = select_storage_target()
ui.info("RAID1 partition 2...")
second = select_storage_target()
return first, second
def setup() -> None:
first, second = _select_pair()
ui.info(f"Encrypting {first.device.path}...")
runner.run(["cryptsetup", "luksFormat", first.device.path], sudo=True)
ui.info(f"Encrypting {second.device.path}...")
runner.run(["cryptsetup", "luksFormat", second.device.path], sudo=True)
runner.run(["cryptsetup", "luksOpen", first.device.path, first.mapper_name], sudo=True)
runner.run(
["cryptsetup", "luksOpen", second.device.path, second.mapper_name], sudo=True
)
runner.run(["cryptsetup", "status", first.mapper_path], sudo=True)
runner.run(["cryptsetup", "status", second.mapper_path], sudo=True)
runner.run(
[
"mkfs.btrfs",
"-m",
"raid1",
"-d",
"raid1",
first.mapper_path,
second.mapper_path,
],
sudo=True,
)
ui.success("Encryption successfull :)")
def _show_luks_devices() -> None:
for name in runner.output(["lsblk", "-dno", "NAME"], check=False).split():
if runner.succeeds(["cryptsetup", "isLuks", f"/dev/{name}"], sudo=True):
ui.info(f"/dev/{name} is a LUKS encrypted storage device.")
def mount_on_boot() -> None:
ui.info("Activate Automount raid1 encrypted storages...")
_show_luks_devices()
first, second = _select_pair()
luks.create_luks_key_and_update_crypttab(first.mapper_name, first.device.path)
ui.info(f'Creating mount folder under "{first.mount_path}"...')
runner.run(["mkdir", "-vp", first.mount_path], sudo=True)
luks.create_luks_key_and_update_crypttab(second.mapper_name, second.device.path)
luks.update_fstab(first.mapper_path, first.mount_path)
ui.success("Installation finished. Please restart :)")

View File

@@ -0,0 +1,88 @@
"""Single-drive LUKS + Btrfs storage: setup, mount, umount, mount-on-boot."""
from lim import device as device_module
from lim import luks, runner, system, ui
from lim.storage.common import select_storage_target
# fdisk answer sequences; empty lines accept the defaults.
CREATE_GPT_TABLE_INPUT = "g\nw\n"
CREATE_PARTITION_INPUT = "n\n\n\n\np\nw\n"
def setup() -> None:
print("Setups disk encryption")
target = select_storage_target()
device_module.overwrite_device(target.device)
ui.info("Creating new GPT partition table...")
runner.run(
["fdisk", "--wipe", "always", target.device.path],
input_text=CREATE_GPT_TABLE_INPUT,
sudo=True,
)
ui.info("Creating partition table...")
runner.run(
["fdisk", "--wipe", "always", target.device.path],
input_text=CREATE_PARTITION_INPUT,
sudo=True,
)
ui.info(f"Encrypt {target.device.path}...")
runner.run(["cryptsetup", "-v", "-y", "luksFormat", target.partition_path], sudo=True)
ui.info("Unlock partition...")
runner.run(
["cryptsetup", "luksOpen", target.partition_path, target.mapper_name], sudo=True
)
ui.info("Create btrfs file system...")
runner.run(["mkfs.btrfs", target.mapper_path], sudo=True)
ui.info(f'Creating mount folder under "{target.mount_path}"...')
runner.run(["mkdir", "-p", target.mount_path], sudo=True)
ui.info("Mount partition...")
runner.run(["mount", target.mapper_path, target.mount_path], sudo=True)
user = system.real_user()
ui.info("Own partition by user...")
runner.run(["chown", "-R", f"{user}:{user}", target.mount_path], sudo=True)
ui.success("Encryption successfull :)")
def mount() -> None:
print("Mounts encrypted storages")
target = select_storage_target()
ui.info("Unlock partition...")
runner.run(
["cryptsetup", "luksOpen", target.partition_path, target.mapper_name], sudo=True
)
ui.info("Mount partition...")
runner.run(["mount", target.mapper_path, target.mount_path], sudo=True)
ui.success("Mounting successfull :)")
def umount() -> None:
print("Unmount encrypted storages")
target = select_storage_target()
ui.info(f"Unmount {target.mapper_path}...")
runner.run(["umount", target.mapper_path], sudo=True)
runner.run(["cryptsetup", "luksClose", target.mapper_name], sudo=True)
ui.success("Successfull :)")
def mount_on_boot() -> None:
print("Automount encrypted storages")
target = select_storage_target()
luks.create_luks_key_and_update_crypttab(target.mapper_name, target.partition_path)
luks.update_fstab(target.mapper_path, target.mount_path)
ui.success("Installation finished. Please restart :)")

35
lim/system.py Normal file
View File

@@ -0,0 +1,35 @@
"""Process-level helpers: privilege handling and user resolution."""
import getpass
import os
import sys
from pathlib import Path
from lim import ui
def is_root() -> bool:
return os.geteuid() == 0
def ensure_root() -> None:
"""Re-execute the current command with sudo when not running as root."""
if is_root():
return
ui.info("Root privileges required. Re-executing with sudo...")
script = str(Path(sys.argv[0]).resolve())
# Deliberate privilege escalation: replace this process with sudo.
os.execvp("sudo", ["sudo", sys.executable, script, *sys.argv[1:]]) # noqa: S606
def real_user() -> str:
"""Return the invoking user, even when running under sudo."""
return os.environ.get("SUDO_USER") or getpass.getuser()
def real_home() -> Path:
"""Home directory of the invoking user, even when running under sudo."""
sudo_user = os.environ.get("SUDO_USER")
if sudo_user:
return Path("/home") / sudo_user
return Path.home()

62
lim/ui.py Normal file
View File

@@ -0,0 +1,62 @@
"""Colored console messages and interactive prompts."""
import sys
_USE_COLOR = sys.stdout.isatty()
def _color(code: str) -> str:
return f"\033[{code}m" if _USE_COLOR else ""
COLOR_RED = _color("31")
COLOR_GREEN = _color("32")
COLOR_YELLOW = _color("33")
COLOR_BLUE = _color("34")
COLOR_MAGENTA = _color("35")
COLOR_CYAN = _color("36")
COLOR_WHITE = _color("37")
COLOR_RESET = _color("0")
def message(color: str, tag: str, text: str) -> None:
print(f"{color}[{tag}]:{COLOR_RESET} {text}")
def question(text: str) -> None:
message(COLOR_MAGENTA, "QUESTION", text)
def info(text: str) -> None:
message(COLOR_BLUE, "INFO", text)
def warning(text: str) -> None:
message(COLOR_YELLOW, "WARNING", text)
def success(text: str) -> None:
message(COLOR_GREEN, "SUCCESS", text)
def error(text: str) -> None:
message(COLOR_RED, "ERROR", text)
def ask(prompt: str) -> str:
question(prompt)
return input().strip()
def confirm(prompt: str) -> bool:
return ask(f"{prompt}(y/N)") == "y"
def header() -> None:
print(
f"\n{COLOR_YELLOW}The\n"
"LINUX IMAGE MANAGER\n"
"is an administration tool designed from and for Kevin Veen-Birkenbach.\n\n"
"Licensed under GNU GENERAL PUBLIC LICENSE Version 3"
f"{COLOR_RESET}\n"
)

118
main.py
View File

@@ -1,118 +1,12 @@
#!/usr/bin/env python3
import subprocess
import os
import argparse
"""Entry point for the Linux Image Manager (lim)."""
import sys
from pathlib import Path
def run_script(script_path, extra_args):
if not os.path.exists(script_path):
print(f"[ERROR] Script not found at {script_path}")
exit(1)
command = ["sudo", "bash", script_path] + extra_args
print(f"[INFO] Running command: {' '.join(command)}")
# Pass the parent's stdout and stderr so that progress output shows in real time.
result = subprocess.run(command, stdout=sys.stdout, stderr=sys.stderr)
if result.returncode != 0:
print(f"[ERROR] Script exited with code {result.returncode}")
exit(result.returncode)
print("[SUCCESS] Script executed successfully.")
sys.path.insert(0, str(Path(__file__).resolve().parent))
def main():
# Use os.path.realpath to get the actual path of this file regardless of symlinks.
repo_root = os.path.dirname(os.path.realpath(__file__))
# Define available scripts along with their descriptions.
setup_scripts = {
"image": {
"path": os.path.join(repo_root, "scripts", "image", "setup.sh"),
"description": (
"Linux Image Setup:\n"
" - Creates partitions and formats them.\n"
" - Transfers the Linux image file to the device.\n"
" - Configures boot and root partitions."
)
},
"single": {
"path": os.path.join(repo_root, "scripts", "encryption", "storage", "single_drive", "setup.sh"),
"description": (
"Single Drive Encryption Setup:\n"
" - Sets up disk encryption using LUKS on one drive.\n"
" - Configures a Btrfs file system for secure storage."
)
},
"raid1": {
"path": os.path.join(repo_root, "scripts", "encryption", "storage", "raid1", "setup.sh"),
"description": (
"RAID1 Encryption Setup:\n"
" - Configures a virtual RAID1 with two drives.\n"
" - Uses LUKS encryption and a Btrfs RAID1 file system for redundancy."
)
},
"backup": {
"path": os.path.join(repo_root, "scripts", "image", "backup.sh"),
"description": (
"Backup Image Setup:\n"
" - Creates an image backup from a memory device to a file.\n"
" - Uses dd to transfer the image from the specified device to an image file."
)
},
"chroot": {
"path": os.path.join(repo_root, "scripts", "image", "chroot.sh"),
"description": (
"Chroot Environment Setup:\n"
" - Mounts partitions and configures the chroot environment for a Linux image.\n"
" - Provides a shell within the Linux image for system maintenance."
)
}
}
parser = argparse.ArgumentParser(
description="Wrapper for executing various scripts from Linux Image Manager.",
epilog=(
"Available script types:\n"
" image - Linux Image Setup\n"
" single - Single Drive Encryption Setup\n"
" raid1 - RAID1 Encryption Setup\n"
" backup - Backup Image Setup\n"
" chroot - Chroot Environment Setup\n\n"
"Additional Options:\n"
" --extra Pass extra parameters to the selected script.\n"
" --auto-confirm Bypass the confirmation prompt before execution.\n"
" --help Display this help message and exit."
),
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--type", required=True, choices=list(setup_scripts.keys()),
help="Select the script type to execute. Options: " + ", ".join(setup_scripts.keys()))
parser.add_argument("--extra", nargs=argparse.REMAINDER, default=[],
help="Extra parameters to pass to the selected script.")
parser.add_argument("--auto-confirm", action="store_true",
help="Automatically confirm execution without prompting the user.")
args = parser.parse_args()
script_info = setup_scripts[args.type]
print("[INFO] Selected script type:", args.type)
print("[INFO] Description:")
print(script_info["description"])
print("[INFO] Script path:", script_info["path"])
if args.extra:
print("[INFO] Extra parameters provided:", " ".join(args.extra))
else:
print("[INFO] No extra parameters provided.")
if not args.auto_confirm:
try:
input("Press Enter to execute the script or Ctrl+C to cancel...")
except KeyboardInterrupt:
print("\n[ERROR] Execution aborted by user.")
exit(1)
run_script(script_info["path"], args.extra)
from lim.cli import main
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n[ERROR] Execution aborted by user.")
exit(1)
main()

41
pyproject.toml Normal file
View File

@@ -0,0 +1,41 @@
[project]
name = "lim"
version = "1.0.0"
description = "Linux Image Manager — manages Linux images and encrypted storage"
requires-python = ">=3.10"
license = { file = "LICENSE.txt" }
dependencies = ["PyYAML>=6"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py310"
line-length = 100
[tool.ruff.lint]
select = ["ALL"]
ignore = [
# This tool's UI is print/prompt based and its purpose is orchestrating
# system commands — these rules contradict the design:
"T201", # print used as console UI
"S603", # subprocess without shell is exactly what runner.py wraps
"S607", # system tools are resolved via PATH on purpose
# Style choices:
"D203", # conflicts with D211 (blank line before class docstring)
"D213", # conflicts with D212 (multi-line docstring summary position)
"D1", # docstrings are not required on every symbol
"COM812", # trailing commas are handled by the formatter
"TRY003", # exception messages are written inline
"EM101", # literal exception messages are fine
"EM102", # f-string exception messages are fine
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"S101", # pytest asserts
"ANN", # test signatures need no annotations
"PLR2004", # literal expectations in assertions
"ARG", # fixtures are injected even when unused
"SLF001", # tests may exercise internal helpers directly
]

View File

@@ -1,161 +0,0 @@
#!/bin/bash
#
# This script contains the global program variables and functions
#
# shellcheck disable=SC2034 #Deactivate checking of unused variables
# shellcheck disable=SC2003 #Deactivate "expr is antiquated"
# shellcheck disable=SC2015 #Deactivate bool hint
# shellcheck disable=SC2005 #Remove useless echo hint
# shellcheck disable=SC2010 #Deactivate ls | grep hint
REPOSITORY_PATH="$(readlink -f "${0}" | sed -e 's/\/scripts\/.*//g')"
CONFIGURATION_PATH="$REPOSITORY_PATH""/configuration/"
PACKAGE_PATH="$CONFIGURATION_PATH""packages/"
TEMPLATE_PATH="$CONFIGURATION_PATH""templates/";
HOME_TEMPLATE_PATH="$TEMPLATE_PATH""home/";
ENCRYPTED_PATH="$REPOSITORY_PATH/.encrypted";
DECRYPTED_PATH="$REPOSITORY_PATH/decrypted";
SCRIPT_PATH="$REPOSITORY_PATH/scripts/";
DATA_PATH="$DECRYPTED_PATH/data";
BACKUP_PATH="$DECRYPTED_PATH/backup";
COLOR_RED=$(tput setaf 1)
COLOR_GREEN=$(tput setaf 2)
COLOR_YELLOW=$(tput setaf 3)
COLOR_BLUE=$(tput setaf 4)
COLOR_MAGENTA=$(tput setaf 5)
COLOR_CYAN=$(tput setaf 6)
COLOR_WHITE=$(tput setaf 7)
COLOR_RESET=$(tput sgr0)
# FUNCTIONS
message(){
echo "$1[$2]:${COLOR_RESET} $3 ";
}
question(){
message "${COLOR_MAGENTA}" "QUESTION" "$1";
}
info(){
message "${COLOR_BLUE}" "INFO" "$1";
}
warning(){
message "${COLOR_YELLOW}" "WARNING" "$1";
}
success(){
message "${COLOR_GREEN}" "SUCCESS" "$1";
}
error(){
if [ -z "$1" ]
then
message="Failed."
else
message="$1"
fi
message "${COLOR_RED}" "ERROR" "$message -> Leaving program."
if declare -f "destructor" > /dev/null
then
info "Calling destructor..."
destructor
else
warning "No destructor defined."
info "Can be that this script left some waste."
fi
exit 1;
}
# Routine to echo the full sd-card-path
set_device_path(){
info "Available devices:"
ls -lasi /dev/ | grep -E "sd|mm"
question "Please type in the name of the device: /dev/" && read -r device
device_path="/dev/$device"
if [ ! -b "$device_path" ]
then
error "$device_path is not valid device."
fi
info "Device path set to: $device_path"
# @see https://www.heise.de/ct/hotline/Optimale-Blockgroesse-fuer-dd-2056768.html
PHYSICAL_BLOCK_SIZE_PATH="/sys/block/$device/queue/physical_block_size"
if [ -f "$PHYSICAL_BLOCK_SIZE_PATH" ]; then
PHYSICAL_BLOCK_SIZE=$(sudo cat $PHYSICAL_BLOCK_SIZE_PATH)
if [ $? -eq 0 ]; then
OPTIMAL_BLOCKSIZE=$((64 * PHYSICAL_BLOCK_SIZE)) || error
else
echo "Unable to read $PHYSICAL_BLOCK_SIZE_PATH"
OPTIMAL_BLOCKSIZE="4K"
fi
else
OPTIMAL_BLOCKSIZE="4K"
fi
info "Optimal blocksize set to: $OPTIMAL_BLOCKSIZE" || error
}
print_partition_table_info() {
echo "##########################################################################################"
echo "Note on Partition Table Deletion:"
echo "---------------------------------------------"
echo "• MBR (Master Boot Record):"
echo " - Typically occupies the first sector (512 bytes), i.e., 1 block."
echo ""
echo "• GPT (GUID Partition Table):"
echo " - Uses a protective MBR (1 block), a GPT header (1 block),"
echo " and usually a partition entry array that takes up about 32 blocks."
echo " - Total: approximately 34 blocks (assuming a 512-byte block size)."
echo ""
echo "Recommendation: For deleting a GPT partition table, use a block size of 512 bytes"
echo " and overwrite at least 34 blocks to ensure the entire table is cleared."
echo "##########################################################################################"
}
overwrite_device() {
# Call the function to display the information.
print_partition_table_info
question "Should $device_path be overwritten with zeros before copying? (y/N/block count)" && read -r copy_zeros_to_device
case "$copy_zeros_to_device" in
y)
info "Overwriting entire device..." &&
dd if=/dev/zero of="$device_path" bs="$OPTIMAL_BLOCKSIZE" status=progress && sync || error "Overwriting $device_path failed."
;;
N|'')
info "Skipping Overwriting..."
;;
''|*[!0-9]*)
error "Invalid input."
;;
*)
if [[ "$copy_zeros_to_device" =~ ^[0-9]+$ ]]; then
info "Overwriting $copy_zeros_to_device blocks..." &&
dd if=/dev/zero of="$device_path" bs="$OPTIMAL_BLOCKSIZE" count="$copy_zeros_to_device" status=progress && sync || error "Overwriting $device_path failed."
else
error "Invalid input. Block count must be a number."
fi
;;
esac
}
get_packages(){
for package_collection in "$@"
do
package_collection_path="$PACKAGE_PATH""$package_collection.txt" &&
echo "$(sed -e "/^#/d" -e "s/#.*//" "$package_collection_path" | tr '\n' ' ')" ||
error
done
}
HEADER(){
echo
echo "${COLOR_YELLOW}The"
echo "LINUX IMAGE MANAGER"
echo "is an administration tool designed from and for Kevin Veen-Birkenbach."
echo
echo "Licensed under GNU GENERAL PUBLIC LICENSE Version 3"
echo "${COLOR_RESET}"
}
HEADER

View File

@@ -1,12 +0,0 @@
#!/bin/bash
# Executes the import script in reverse mode
# @author Kevin Veen-Birkenbach [aka. Frantz]
# shellcheck source=/dev/null # Deactivate SC1090
# shellcheck disable=SC2015 # Deactivating bool hint
source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1)
bash "$SCRIPT_PATH""data/import-from-system.sh" reverse
info "Setting right permissions for importet files..." &&
chmod -R 700 ~/.ssh &&
chmod 600 ~/.ssh/id_rsa &&
chmod 600 ~/.ssh/id_rsa.pub || error "Failed to set correct ssh permissions"
chown -R "$USER":"$USER" ~ || warning "Not all files could be owned by user \"$USER\"..."

View File

@@ -1,75 +0,0 @@
#!/bin/bash
#
# Imports data from the system
# @param $1 If the first parameter is "reverse" the data will be exported to the system
#
# shellcheck source=/dev/null # Deactivate SC1090
# shellcheck disable=SC2143 # Comparing with -z allowed
# shellcheck disable=SC2015 # Deactivating bool hint
source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1)
declare -a BACKUP_LIST=("$HOME/.ssh/" \
"$HOME/.gitconfig" \
"$HOME/.atom/config.cson" \
"$HOME/.projectlibre/projectlibre.conf" \
"$HOME/.local/share/rhythmbox/rhythmdb.xml" \
"$HOME/.config/keepassxc/keepassxc.ini" \
"$HOME/Documents/certificates/" \
"$HOME/Documents/security/" \
"$HOME/Documents/identity/" \
"$HOME/Documents/health/" \
"$HOME/Documents/licenses/");
if [ -z "$(mount | grep "$DECRYPTED_PATH")" ]
then
info "The decrypted folder $DECRYPTED_PATH is locked. You need to unlock it!" &&
bash "$SCRIPT_PATH""encryption/data/unlock.sh" || error "Unlocking failed.";
fi
if [ "$1" = "reverse" ]
then
MODE="export"
else
MODE="import"
fi
CONCRETE_BACKUP_FOLDER="$BACKUP_PATH/$MODE/$(date '+%Y%m%d%H%M%S')"
mkdir -p "$CONCRETE_BACKUP_FOLDER" || error "Failed to create \"$CONCRETE_BACKUP_FOLDER\"."
for system_item_path in "${BACKUP_LIST[@]}";
do
data_item_path="$DATA_PATH$system_item_path"
if [ "$MODE" = "export" ]
then
destination="$system_item_path"
source="$data_item_path"
info "Export data from $source to $destination..."
else
source="$system_item_path"
destination="$data_item_path"
info "Import data from $source to $destination..."
fi
if [ -f "$destination" ]
then
info "The destination file allready exists!" &&
info "Difference:" &&
diff "$destination" "$source"
fi
destination_dir=$(dirname "$destination")
mkdir -p "$destination_dir" || error "Failed to create \"$destination_dir\"."
if [ -f "$source" ]
then
backup_dir=$(dirname "$CONCRETE_BACKUP_FOLDER/$system_item_path");
mkdir -p "$backup_dir" || error "Failed to create \"$backup_dir\"."
info "Copy data from $source to $destination..."
rsync -abcEPuvW --backup-dir="$backup_dir" "$source" "$destination" || error
else
if [ -d "$source" ]
then
mkdir -p "$destination" || error "Failed to create \"$destination\"."
backup_dir="$CONCRETE_BACKUP_FOLDER/$system_item_path";
mkdir -p "$backup_dir" || error "Failed to create \"$backup_dir\"."
info "Copy data from directory $source to directory $destination..."
rsync -abcEPuvW --delete --backup-dir="$backup_dir" "$source" "$destination" || error
else
warning "$source doesn't exist. Copying data is not possible."
fi
fi
done

View File

@@ -1,14 +0,0 @@
#!/bin/bash
#
# Locks the data
# @author Kevin Veen-Birkenbach [aka. Frantz]
#
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck source=/dev/null # Deactivate SC1090
source "$(dirname "$(readlink -f "${0}")")/../../base.sh" || (echo "Loading base.sh failed." && exit 1)
info "Locking directory $DECRYPTED_PATH..." &&
fusermount -u "$DECRYPTED_PATH" || error "Unmounting failed."
info "Data is now encrypted."
info "Removing directory $DECRYPTED_PATH..." &&
rmdir "$DECRYPTED_PATH" || error

View File

@@ -1,17 +0,0 @@
#!/bin/bash
#
# Unlocks the data
# @author Kevin Veen-Birkenbach [aka. Frantz]
#
# shellcheck source=/dev/null # Deactivate SC1090
# shellcheck disable=SC2015 # Deactivating bool hint
source "$(dirname "$(readlink -f "${0}")")/../../base.sh" || (echo "Loading base.sh failed." && exit 1)
info "Unlocking directory $DECRYPTED_PATH..."
if [ ! -d "$DECRYPTED_PATH" ]
then
info "Creating directory $DECRYPTED_PATH..." &&
mkdir "$DECRYPTED_PATH" || error
fi
info "Decrypting directory $DECRYPTED_PATH to $DECRYPTED_PATH..." &&
encfs "$ENCRYPTED_PATH" "$DECRYPTED_PATH" || error
echo "ATTENTION: DATA IS NOW DECRYPTED!"

View File

@@ -1,2 +0,0 @@
# Storage
For security reasons storages **SHOULD** be encrypted with [LUKS](https://de.wikipedia.org/wiki/Dm-crypt#Erweiterung_mit_LUKS). To keep it standardized and easy this scripts will use [btrfs](https://de.wikipedia.org/wiki/Btrfs) as file system.

View File

@@ -1,86 +0,0 @@
#!/bin/bash
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck disable=SC2034 # Unused variables
# shellcheck disable=SC2154 # Referenced but not assigned
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
# shellcheck disable=SC2001 # See if you can use ${variable//search/replace} instead
source "$(dirname "$(readlink -f "${0}")")/../../../base.sh" || (echo "Loading base.sh failed." && exit 1)
set_device_mount_partition_and_mapper_paths(){
set_device_path &&
mapper_name="encrypteddrive-$device" &&
mapper_path="/dev/mapper/$mapper_name" &&
mount_path="/media/$mapper_name" &&
partition_path="$device_path""1" &&
info "mapper name set to : $mapper_name" &&
info "mapper path set to : $mapper_path" &&
info "mount path set to : $mount_path" ||
error
}
# @var $1 mapper_path
# @var $2 partition_path
create_luks_key_and_update_cryptab(){
LUKS_KEY_DIRECTORY="/etc/luks-keys/" &&
info "Creating luks-key-directory..." &&
sudo mkdir $LUKS_KEY_DIRECTORY || warning "Directory exists: $LUKS_KEY_DIRECTORY" || error
luks_key_name="$1.keyfile" &&
secret_key_path="$LUKS_KEY_DIRECTORY$luks_key_name" &&
info "Generate secret key under: $secret_key_path" || error
if [ -f "$secret_key_path" ]
then
warning "File already exists. Overwriting!"
fi
sudo dd if=/dev/urandom of="$secret_key_path" bs=512 count=8 && sync &&
info "Opening and closing device to verify that everything works fine..." &&
sudo cryptsetup -v luksClose "$1" || info "No need to luksClose $1. Device isn't open." &&
sudo cryptsetup luksAddKey $2 $secret_key_path &&
sudo cryptsetup -v luksOpen "$2" "$1" --key-file="$secret_key_path" &&
sudo cryptsetup -v luksClose "$1" &&
info "Reading UUID..." &&
uuid_line=$(sudo cryptsetup luksDump "$2" | grep "UUID") &&
uuid=$(echo "${uuid_line/UUID:/""}"|sed -e "s/[[:space:]]\+//g") &&
crypttab_path="/etc/crypttab" &&
crypttab_entry="$1 UUID=$uuid $secret_key_path luks" &&
info "Adding crypttab entry..." || error
if sudo grep -q "$crypttab_entry" "$crypttab_path";
then
warning "File $crypttab_path already contains the following entry:" &&
echo "$crypttab_entry" &&
info "Skipped." ||
error
else
sudo sh -c "echo '$crypttab_entry' >> $crypttab_path" ||
error
fi
info "The file $crypttab_path contains now the following:" &&
sudo cat $crypttab_path ||
error
}
# @var $1 mapper_name
# @var $2 mount_path
#
# If mount doesn't work adapt it manually to
# @see https://gist.github.com/MaxXor/ba1665f47d56c24018a943bb114640d7
update_fstab(){
fstab_path="/etc/fstab"
fstab_entry="$1 $2 btrfs defaults 0 2"
info "Adding fstab entry..."
if sudo grep -q "$fstab_entry" "$fstab_path"; then
warning "File $fstab_path contains allready a the following entry:" &&
echo "$fstab_entry" &&
info "Skipped." ||
error
else
sudo sh -c "echo '$fstab_entry' >> $fstab_path" ||
error
fi
info "The file $fstab_path contains now the following:" &&
sudo cat $fstab_path ||
error
}

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck disable=SC2034 # Unused variables
# shellcheck disable=SC2154 # Referenced but not assigned
source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1)
set_raid1_devices_mount_partition_and_mapper_paths(){
info "RAID1 partition 1..." &&
set_device_mount_partition_and_mapper_paths &&
partition_path_1=$partition_path &&
mapper_name_1=$mapper_name &&
mapper_path_1=$mapper_path &&
mount_path_1=$mount_path &&
device_path_1=$device_path &&
info "RAID1 partition 2..." &&
set_device_mount_partition_and_mapper_paths &&
partition_path_2=$partition_path &&
mapper_name_2=$mapper_name &&
mapper_path_2=$mapper_path &&
mount_path_2=$mount_path &&
device_path_2=$device_path || error
}

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
# shellcheck disable=SC2154 # Referenced but not assigned
# shellcheck disable=SC2015 #Deactivate bool hint
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
info "Activate Automount raid1 encrypted storages..." &&
echo ""
for dev in $(lsblk -dno NAME); do
if sudo cryptsetup isLuks /dev/$dev 2>/dev/null; then
info "/dev/$dev is a LUKS encrypted storage device."
fi
done
set_raid1_devices_mount_partition_and_mapper_paths &&
create_luks_key_and_update_cryptab "$mapper_name_1" "$device_path_1" &&
info "Creating mount folder unter \"$mount_path_1\"..." &&
sudo mkdir -vp "$mount_path_1" &&
create_luks_key_and_update_cryptab "$mapper_name_2" "$device_path_2" &&
update_fstab "$mapper_path_1" "$mount_path_1" &&
success "Installation finished. Please restart :)" ||
error

View File

@@ -1,23 +0,0 @@
#!/bin/bash
# @author Kevin Veen-Birkenbach [kevin@veen.world]
# @see https://balaskas.gr/btrfs/raid1.html
# @see https://mutschler.eu/linux/install-guides/ubuntu-btrfs-raid1/
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck disable=SC2154 # Referenced but not assigned
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
set_raid1_devices_mount_partition_and_mapper_paths
info "Encrypting $device_path_1..." &&
cryptsetup luksFormat "$device_path_1" &&
info "Encrypting $device_path_2..." &&
cryptsetup luksFormat "$device_path_2" &&
blkid | tail -2 &&
cryptsetup luksOpen "$device_path_1" "$mapper_name_1" &&
cryptsetup luksOpen "$device_path_2" "$mapper_name_2" &&
cryptsetup status "$mapper_path_1" &&
cryptsetup status "$mapper_path_2" &&
mkfs.btrfs -m raid1 -d raid1 "$mapper_path_1" "$mapper_path_2" &&
success "Encryption successfull :)" ||
error

View File

@@ -1,3 +0,0 @@
#!/bin/bash
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1)

View File

@@ -1,18 +0,0 @@
#!/bin/bash
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck disable=SC2154 # Referenced but not assigned
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
echo "Mounts encrypted storages"
set_device_mount_partition_and_mapper_paths
info "Unlock partition..." &&
sudo cryptsetup luksOpen "$partition_path" "$mapper_name" ||
error
info "Mount partition..." &&
sudo mount "$mapper_path" "$mount_path" ||
error
success "Mounting successfull :)"

View File

@@ -1,13 +0,0 @@
#!/bin/bash
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
# shellcheck disable=SC2154 # Referenced but not assigned
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
echo "Automount encrypted storages"
echo
set_device_mount_partition_and_mapper_paths
create_luks_key_and_update_cryptab "$mapper_name" "$partition_path"
update_fstab "$mapper_path" "$mount_path"
success "Installation finished. Please restart :)"

View File

@@ -1,50 +0,0 @@
#!/bin/bash
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck disable=SC2154 # Referenced but not assigned
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
echo "Setups disk encryption"
set_device_mount_partition_and_mapper_paths
overwrite_device
info "Creating new GPT partition table..."
( echo "g" # create a new empty GPT partition table
echo "w" # Write partition table
)| sudo fdisk --wipe always "$device_path" ||
error
info "Creating partition table..."
( echo "n" # Create new partition
echo "" # Accept default
echo "" # Accept default
echo "" # Accept default
echo "p" # Create GPT partition table
echo "w" # Write partition table
)| sudo fdisk --wipe always "$device_path" ||
error
info "Encrypt $device_path..." &&
sudo cryptsetup -v -y luksFormat "$partition_path" ||
error
info "Unlock partition..." &&
sudo cryptsetup luksOpen "$partition_path" "$mapper_name" ||
error
info "Create btrfs file system..." &&
sudo mkfs.btrfs "$mapper_path" || error
info "Creating mount folder unter \"$mount_path\"..." &&
sudo mkdir -p "$mount_path" || error
info "Mount partition..." &&
sudo mount "$mapper_path" "$mount_path" ||
error
info "Own partition by user..." &&
sudo chown -R "$USER":"$USER" "$mount_path" ||
error
success "Encryption successfull :)"

View File

@@ -1,15 +0,0 @@
#!/bin/bash
# shellcheck disable=SC1090 # Can't follow non-constant source. Use a directive to specify location.
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck disable=SC2154 # Referenced but not assigned
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
echo "Unmount encrypted storages"
set_device_mount_partition_and_mapper_paths
info "Unmount $mapper_path..."
sudo umount "$mapper_path" &&
sudo cryptsetup luksClose "$mapper_path" ||
error
success "Successfull :)"

View File

@@ -1,30 +0,0 @@
#!/bin/bash
# shellcheck disable=SC2010 # ls | grep allowed
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck source=/dev/null # Deactivate SC1090
# shellcheck disable=SC2154 # Deactivate not referenced link
# shellcheck disable=SC2015 # Deactivate bools hints
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
info "Backupscript for memory devices started..."
echo
set_device_path
while [ "$path" == "" ]
do
echo "Bitte Backupimagepfad+Namen zu $PWD eingeben:"
read -r path
if [ "${path:0:1}" == "/" ]
then
ofi="$path.img"
else
ofi="$PWD/$path.img"
fi
done
info "Input file: $device_path"
info "Output file: $ofi"
question "Please confirm by pushing \"Enter\". To cancel use \"Ctrl + Alt + C\""
read -r bestaetigung && echo "$bestaetigung";
info "Imagetransfer starts. This can take a while..." &&
dd if="$device_path" of="$ofi" bs=1M status=progress && sync || error "\"dd\" failed.";
success "Imagetransfer successfull." && exit 0;

View File

@@ -1,114 +0,0 @@
#!/bin/bash
#
# Offers base functions for the image management
#
# shellcheck disable=SC2034 #Deactivate checking of unused variables
# shellcheck disable=SC2010 # ls | grep allowed
# shellcheck source=/dev/null # Deactivate SC1090
# shellcheck disable=SC2015 # Deactivate bools hints
# shellcheck disable=SC2154 # Deactivate referenced but not assigned hints
source "$(dirname "$(readlink -f "${0}")")/../base.sh" || (echo "Loading base.sh failed." && exit 1)
# Writes the full partition name
# @parameter $1 is device path
# @parameter $2 is the partition number
echo_partition_name(){
if [ "${device_path:5:1}" != "s" ]
then
echo "$device_path""p""$1"
else
echo "$device_path$1"
fi
}
set_partition_paths(){
info "Setting partition and mapper paths..."
boot_partition_path=$(echo_partition_name "1")
root_partition_path=$(echo_partition_name "2")
root_mapper_path=$root_partition_path
}
make_mount_folders(){
info "Preparing mount paths..." &&
boot_mount_path="$working_folder_path""boot/" &&
root_mount_path="$working_folder_path""root/" &&
mkdir -v "$boot_mount_path" &&
mkdir -v "$root_mount_path" ||
error
}
make_working_folder(){
working_folder_path="/tmp/linux-image-manager-$(date +%s)/" &&
info "Create temporary working folder in $working_folder_path" &&
mkdir -v "$working_folder_path" ||
error
}
decrypt_root(){
if [ "$(blkid "$root_partition_path" -s TYPE -o value)" == "crypto_LUKS" ]
then
root_partition_uuid=$(blkid "$root_partition_path" -s UUID -o value) &&
root_mapper_name="linux-image-manager-$root_partition_uuid" &&
root_mapper_path="/dev/mapper/$root_mapper_name" &&
info "Decrypting of $root_partition_path is neccessary..." &&
sudo cryptsetup -v luksOpen "$root_partition_path" "$root_mapper_name" || error
fi
}
mount_partitions(){
info "Mount boot and root partition..." &&
mount -v "$boot_partition_path" "$boot_mount_path" &&
mount -v "$root_mapper_path" "$root_mount_path" &&
info "Settind uuid variables..." &&
root_partition_uuid=$(blkid "$root_partition_path" -s UUID -o value) &&
boot_partition_uuid=$(blkid "$boot_partition_path" -s UUID -o value) &&
info "The following mounts refering this setup exist:" && mount | grep "$working_folder_path" ||
error
}
destructor(){
info "Cleaning up..."
info "Unmounting everything..."
umount -lv "$chroot_dev_pts_mount_path" || warning "Umounting $chroot_dev_pts_mount_path failed!"
umount -lv "$chroot_dev_mount_path" || warning "Umounting $chroot_dev_mount_path failed!"
umount -v "$chroot_proc_mount_path" || warning "Umounting $chroot_proc_mount_path failed!"
umount -v "$chroot_sys_mount_path" || warning "Umounting $chroot_sys_mount_path failed!"
umount -v "$root_mount_path""boot/" || warning "Umounting $root_mount_path""boot/ failed!"
umount -v "$root_mount_path" || warning "Umounting $root_mount_path failed!"
umount -v "$boot_mount_path" || warning "Umounting $boot_mount_path failed!"
info "Deleting mount folders..."
rmdir -v "$root_mount_path" || warning "Removing $root_mount_path failed!"
rmdir -v "$boot_mount_path" || warning "Removing $boot_mount_path failed!"
rmdir -v "$working_folder_path" || warning "Removing $working_folder_path failed!"
if [ "$(blkid "$root_partition_path" -s TYPE -o value)" == "crypto_LUKS" ]
then
info "Trying to close decrypted $root_mapper_name..." &&
sudo cryptsetup -v luksClose "$root_mapper_name" || warning "Failed."
fi
}
mount_chroot_binds(){
info "Mount chroot environments..." &&
chroot_sys_mount_path="$root_mount_path""sys/" &&
chroot_proc_mount_path="$root_mount_path""proc/" &&
chroot_dev_mount_path="$root_mount_path""dev/" &&
chroot_dev_pts_mount_path="$root_mount_path""dev/pts" &&
mount --bind "$boot_mount_path" "$root_mount_path""boot" &&
mount --bind /dev "$chroot_dev_mount_path" &&
mount --bind /sys "$chroot_sys_mount_path" &&
mount --bind /proc "$chroot_proc_mount_path" &&
mount --bind /dev/pts "$chroot_dev_pts_mount_path" ||
error
}
copy_qemu(){
info "Copy qemu binary..." &&
cp -v /usr/bin/qemu-arm-static "$root_mount_path""usr/bin/" ||
error
}
copy_resolve_conf(){
info "Copy resolve.conf..." &&
cp --remove-destination -v /etc/resolv.conf "$root_mount_path""etc/" ||
warning "Failed. Propably there is no internet connection available."
}

View File

@@ -1,31 +0,0 @@
#!/bin/bash
# shellcheck source=/dev/null # Deactivate SC1090
# shellcheck disable=SC2015 # Deactivating bool hint
# shellcheck disable=SC2154 # Deactivate not referenced link
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
info "Starting chroot..."
set_device_path
make_working_folder
make_mount_folders
set_partition_paths
decrypt_root
mount_partitions
mount_chroot_binds
copy_qemu
copy_resolve_conf
info "Bash shell starts..." &&
chroot "$root_mount_path" /bin/bash ||
error
destructor

View File

@@ -1,714 +0,0 @@
#!/bin/bash
# shellcheck disable=SC2010 # ls | grep allowed
# shellcheck source=/dev/null # Deactivate SC1090
# shellcheck disable=SC2015 # Deactivate bools hints
# shellcheck disable=SC2154 # Deactivate not referenced link
# @see https://wiki.polaire.nl/doku.php?id=archlinux-raspberry-encrypted
source "$(dirname "$(readlink -f "${0}")")/base.sh" || (echo "Loading base.sh failed." && exit 1)
install(){
info "Installing $1..."
case "$distribution" in
"arch"|"manjaro")
echo "pacman --noconfirm -S --needed $1" | chroot "$root_mount_path" /bin/bash || error
;;
"moode"|"retropie")
echo "yes | apt install $1" | chroot "$root_mount_path" /bin/bash || error
;;
*)
error "Package manager not supported."
;;
esac
}
replace_in_file() {
# Assign the first function argument to the local variable search_string
local search_string=$1
# Assign the second function argument to the local variable replace_string
local replace_string=$2
# Assign the third function argument to the local variable file_path
local file_path=$3
# Create a temporary file and store its path in temp_file
temp_file=$(mktemp)
# Use sed to replace the search_string with replace_string in the file at file_path
# Write the output to the temporary file
sed "s/$search_string/$replace_string/g" "$file_path" > "$temp_file"
# Compare the original file with the temporary file
if cmp -s "$file_path" "$temp_file"; then
# If files are identical, remove the temporary file and signal an error
rm -f "$temp_file"
error "Error: Search string '$search_string' not found in $file_path."
else
# If files are different, move the temporary file to overwrite the original file
mv "$temp_file" "$file_path"
fi
}
info "Setupscript for images started..."
info "Checking if root..."
if [ "$(id -u)" != "0" ];then
error "This script must be executed as root!"
fi
make_working_folder
info "Configure user..." &&
question "Please type in a valid working username:" && read -r origin_username &&
getent passwd "$origin_username" > /dev/null 2 || error "User $origin_username doesn't exist."
origin_user_home="/home/$origin_username/"
info "Image routine starts..."
image_folder="$origin_user_home""Software/Images/";
info "The images will be stored in \"$image_folder\"."
if [ ! -d "$image_folder" ]; then
info "Folder \"$image_folder\" doesn't exist. It will be created now." &&
mkdir -v "$image_folder" ||
error
fi
set_device_path
if mount | grep -q "$device_path"
then
error "Device $device_path is allready mounted. Umount with \"umount $device_path*\"."
fi
question "Which operation system would you like to use [linux,windows,...]?" && read -r operation_system || error
case "$operation_system" in
"linux")
question "Which distribution should be used [arch,moode,retropie,manjaro,torbox...]?" && read -r distribution || error
case "$distribution" in
"android-x86")
base_download_url="https://www.fosshub.com/Android-x86.html?dwl=android-x86_64-9.0-r2.iso";
image_name="android-x86_64-9.0-r2.iso"
image_checksum="f7eb8fc56f29ad5432335dc054183acf086c539f3990f0b6e9ff58bd6df4604e"
boot_size="+500M"
;;
"torbox")
base_download_url="https://www.torbox.ch/data/";
image_name="torbox-20220102-v050.gz"
image_checksum="0E1BA7FFD14AAAE5F0462C8293D95B62C3BF1D9E726E26977BD04772C55680D3"
boot_size="+200M"
;;
"arch")
question "Which Raspberry Pi will be used (e.g.: 1, 2, 3b, 3b+, 4...):" && read -r raspberry_pi_version
boot_size="+500M"
base_download_url="http://os.archlinuxarm.org/os/";
case "$raspberry_pi_version" in
"1")
image_name="ArchLinuxARM-rpi-armv7-latest.tar.gz"
luks_memory_cost="64000"
;;
"2")
image_name="ArchLinuxARM-rpi-armv7-latest.tar.gz"
luks_memory_cost="128000"
;;
"3b" | "3b+")
image_name="ArchLinuxARM-rpi-aarch64-latest.tar.gz"
luks_memory_cost="128000"
;;
"4" )
image_name="ArchLinuxARM-rpi-aarch64-latest.tar.gz"
luks_memory_cost="256000"
;;
*)
error "Version $raspberry_pi_version isn't supported."
;;
esac
;;
"manjaro")
question "Which version(e.g.:architect,gnome) should be used:" && read -r gnome_version
boot_size="+500M"
case "$gnome_version" in
"architect")
image_checksum="6b1c2fce12f244c1e32212767a9d3af2cf8263b2"
base_download_url="https://osdn.net/frs/redir.php?m=dotsrc&f=%2Fstorage%2Fg%2Fm%2Fma%2Fmanjaro%2Farchitect%2F20.0%2F";
image_name="manjaro-architect-20.0-200426-linux56.iso"
;;
"gnome")
question "Which release(e.g.:20,21,raspberrypi) should be used:" && read -r release
case "$release" in
"20")
image_checksum="2df3697908483550d4a473815b08c1377e6b6892"
base_download_url="https://osdn.net/projects/manjaro-archive/storage/gnome/20.0/"
image_name="manjaro-gnome-20.0-200426-linux56.iso"
;;
"21")
base_download_url="https://download.manjaro.org/gnome/21.3.7/"
image_name="manjaro-gnome-21.3.7-220816-linux515.iso"
;;
"22")
base_download_url="https://download.manjaro.org/gnome/22.1.3/"
image_name="manjaro-gnome-22.1.3-230529-linux61.iso"
;;
"24")
base_download_url="https://download.manjaro.org/gnome/24.2.1/"
image_name="manjaro-gnome-24.2.1-241216-linux612.iso"
;;
"25")
base_download_url="https://download.manjaro.org/gnome/25.0.10/"
image_name="manjaro-gnome-25.0.10-251013-linux612.iso"
;;
"raspberrypi")
# at the moment just optimized for raspberry pi 4
base_download_url="https://github.com/manjaro-arm/rpi4-images/releases/download/23.02/"
image_name="Manjaro-ARM-gnome-rpi4-23.02.img.xz"
luks_memory_cost="256000"
raspberry_pi_version="4"
;;
*)
error "Gnome Version $gnome_version isn't supported."
;;
esac
;;
esac
;;
"moode")
boot_size="+200M"
image_checksum="185cbc9a4994534bb7a4bc2744c78197"
base_download_url="https://github.com/moode-player/moode/releases/download/r651prod/"
image_name="moode-r651-iso.zip";
;;
"retropie")
boot_size="+500M"
question "Which version(e.g.:1,2,3,4) should be used:" && read -r raspberry_pi_version
base_download_url="https://github.com/RetroPie/RetroPie-Setup/releases/download/4.8/";
case "$raspberry_pi_version" in
"1")
image_checksum="95a6f84453df36318830de7e8507170e"
image_name="retropie-buster-4.8-rpi1_zero.img.gz"
;;
"2" | "3")
image_checksum="224e64d8820fc64046ba3850f481c87e"
image_name="retropie-buster-4.8-rpi2_3_zero2w.img.gz"
;;
"4")
image_checksum="b5daa6e7660a99c246966f3f09b4014b"
image_name="retropie-buster-4.8-rpi4_400.img.gz"
;;
esac
;;
esac
question "Should the system be encrypted?(y/N)" && read -r encrypt_system
info "Generating os-image..."
download_url="$base_download_url$image_name"
image_path="$image_folder$image_name"
question "Should the image download be forced?(y/N)" && read -r force_image_download
if [ "$force_image_download" = "y" ]
then
if [ -f "$image_path" ]
then
info "Removing image $image_path." &&
rm "$image_path" || error "Removing image \"$image_path\" failed."
else
info "Forcing download wasn't neccessary. File $image_path doesn't exist."
fi
fi
info "Start Download procedure..."
if [ -f "$image_path" ]
then
info "Image exist local. Download skipped."
else
info "Image \"$image_name\" doesn't exist under local path \"$image_path\"." &&
info "Image \"$image_name\" gets downloaded from \"$download_url\"..." &&
wget "$download_url" -O "$image_path" || error "Download from \"$download_url\" failed."
fi
;;
*)
info "Available images:"
ls -l "$image_folder"
question "Which image would you like to use?" && read -r image_name || error
image_path="$image_folder$image_name"
;;
esac
info "Verifying image..."
info "Verifying checksum..."
if [ -z "$image_checksum" ]; then
for ext in sha1 sha512 md5; do
sha_download_url="$download_url.$ext"
info "Image Checksum is not defined. Try to download image signature from $sha_download_url."
if wget -q --method=HEAD "$sha_download_url"; then
image_checksum="$(wget $sha_download_url -q -O - | cut -d ' ' -f1)"
info "Defined image_checksum as $image_checksum"
break
else
warning "No checksum found under $sha_download_url."
fi
done
fi
if [[ -v image_checksum ]]; then
info "A checksum is defined for the image."
info "Checksums verify file integrity to ensure that the file was not corrupted during download."
info "The script will try verifying the integrity using MD5, then SHA1, and finally SHA256 if needed."
info "Trying MD5 checksum verification..."
(info "Checking md5 checksum..." && echo "$image_checksum $image_path" | md5sum -c -) ||
(warning "MD5 verification failed. This may indicate data corruption." &&
info "Trying SHA1 checksum verification for a secondary integrity check..." &&
info "Checking sha1 checksum..." && echo "$image_checksum $image_path" | sha1sum -c -) ||
(warning "SHA1 verification failed. Attempting SHA256 verification for thoroughness." &&
info "SHA256 provides a more robust check and is used as a final integrity measure." &&
info "Checking sha256 checksum..." && echo "$image_checksum $image_path" | sha256sum -c -) ||
error "Verification failed. HINT: Force the download of the image."
else
warning "No checksum is defined. Skipping checksum verification."
fi
info "Note: Checksums verify integrity but do not confirm authenticity."
info "Proceeding to signature verification, which ensures the file comes from a trusted source."
signature_download_url="$download_url.sig"
info "Attempting to download the image signature from: $signature_download_url"
info "Try to download image signature from $signature_download_url."
if wget -q --method=HEAD "$signature_download_url"; then
signature_name="${image_name}.sig"
signature_path="${image_folder}${signature_name}"
info "Download the signature file"
if wget -q -O "$signature_path" "$signature_download_url"; then
info "Extract the key ID from the signature file"
key_id=$(gpg --status-fd 1 --verify "$signature_path" "$image_path" 2>&1 | grep 'NO_PUBKEY' | awk '{print $NF}')
if [ -n "$key_id" ]; then
info "Check if the key is already in the keyring"
if gpg --list-keys "$key_id" > /dev/null 2>&1; then
info "Key $key_id already in keyring."
else
info "Import the public key"
gpg --keyserver keyserver.ubuntu.com --recv-keys "$key_id"
fi
info "Verify the signature again after importing the key"
if gpg --verify "$signature_path" "$image_path"; then
info "Signature verification succeeded."
else
warning "Signature verification failed."
fi
else
warning "No public key found in the signature file."
fi
else
warning "Failed to download the signature file."
fi
else
warning "No signature found under $signature_download_url."
fi
make_mount_folders
set_partition_paths
question "Which filesystem should be used? E.g.:btrfs,ext4... (none):" && read -r root_filesystem
question "Should the image be transfered to $device_path?(y/N)" && read -r transfer_image
if [ "$transfer_image" = "y" ]
then
question "Should the partition table of $device_path be deleted?(y/N)" && read -r delete_partition_table
if [ "$delete_partition_table" = "y" ]
then
info "Deleting..." &&
wipefs -a "$device_path" || error
else
info "Skipping partition table deletion..."
fi
overwrite_device
info "Starting image transfer..."
if [ "$distribution" = "arch" ]
then
# Set default size of the boot partition
boot_size=${boot_size:-"+500M"}
# Use the provided size or the default size
info "The boot partition will be set to $boot_size."
# Partitioning with the specified size
info "Creating partitions..." &&
(
echo "o" # Type o. This will clear out any partitions on the drive.
echo "p" # Type p to list partitions. There should be no partitions left
echo "n" # Type n,
echo "p" # then p for primary,
echo "1" # 1 for the first partition on the drive,
echo "" # Default start sector
echo "$boot_size" # Size of the boot partition
echo "t" # Type t,
echo "c" # then c to set the first partition to type W95 FAT32 (LBA).
echo "n" # Type n,
echo "p" # then p for primary,
echo "2" # 2 for the second partition on the drive,
echo "" # Default start sector
echo "" # Default end sector
echo "w" # Write the partition table and exit by typing w.
) | fdisk "$device_path" || error
info "Format boot partition..." &&
mkfs.vfat "$boot_partition_path" || error
if [ "$encrypt_system" == "y" ]
then
# Check if luks_memory_cost is defined and set the luksAddKey command accordingly
# @see https://chatgpt.com/share/008ea5f1-670c-467c-8320-1ca67f25ac9a
if [ -n "$luks_memory_cost" ]; then
info "Formating $root_partition_path with LUKS with --pbkdf-memory set to $luks_memory_cost" &&
sudo cryptsetup -v luksFormat -c aes-xts-plain64 -s 512 -h sha512 --use-random -i 1000 --pbkdf-memory "$luks_memory_cost" "$root_partition_path" || error
else
info "Formating $root_partition_path with LUKS" &&
sudo cryptsetup -v luksFormat -c aes-xts-plain64 -s 512 -h sha512 --use-random -i 1000 "$root_partition_path" || error
fi
decrypt_root || error
fi
info "Format root partition..." &&
"mkfs.$root_filesystem" -f "$root_mapper_path" || error
mount_partitions;
info "Root files will be transfered to device..." &&
bsdtar -xpf "$image_path" -C "$root_mount_path" &&
sync ||
error
info "Boot files will be transfered to device..." &&
mv -v "$root_mount_path""boot/"* "$boot_mount_path" ||
error
elif [ "${image_path: -4}" = ".zip" ]
then
info "Transfering .zip file..." &&
unzip -p "$image_path" | sudo dd of="$device_path" bs="$OPTIMAL_BLOCKSIZE" conv=fsync status=progress || error "DD $image_path to $device_path failed." &&
sync ||
error
elif [ "${image_path: -3}" = ".gz" ]
then
info "Transfering .gz file..." &&
gunzip -c "$image_path" | sudo dd of="$device_path" bs="$OPTIMAL_BLOCKSIZE" conv=fsync status=progress &&
sync ||
error
elif [ "${image_path: -4}" = ".iso" ]
then
info "Transfering .iso file..." &&
pv "$image_path" | sudo dd of="$device_path" bs="$OPTIMAL_BLOCKSIZE" conv=fsync &&
sync ||
error
elif [ "${image_path: -3}" = ".xz" ]
then
info "Transferring .xz file..." &&
unxz -c "$image_path" | sudo dd of="$device_path" bs="$OPTIMAL_BLOCKSIZE" conv=fsync status=progress &&
sync ||
error
else
error "Image transfer for operation system \"$distribution\" is not supported yet!";
fi
else
info "Skipping image transfer..."
fi
# Execute Raspberry Pi specific procedures
if [ -n "$raspberry_pi_version" ]
then
info "Start regular mounting procedure..."
if mount | grep -q "$boot_partition_path"
then
info "$boot_partition_path is allready mounted..."
else
if mount | grep -q "$root_mapper_path"
then
info "$root_mapper_path is allready mounted..."
else
decrypt_root
mount_partitions
fi
fi
fstab_path="$root_mount_path""etc/fstab" &&
fstab_search_string=$(echo "/dev/mmcblk0p1"| sed -e 's/[\/&]/\\&/g') &&
fstab_replace_string=$(echo "UUID=$boot_partition_uuid"| sed -e 's/[\/&]/\\&/g') &&
info "Seeding UUID to $fstab_path to avoid path conflicts..." &&
sed -i "s/$fstab_search_string/$fstab_replace_string/g" "$fstab_path" &&
info "Content of $fstab_path:$(cat "$fstab_path")" || error
info "Define target paths..." &&
administrator_username="administrator"
target_home_path="$root_mount_path""home/" &&
default_username=$(ls "$target_home_path") &&
question "Should the $default_username be renamed to $administrator_username? (y/N):" && read -r rename_decision
if [ "$rename_decision" == "y" ];
then
variable_old_username="$default_username" &&
target_username="$administrator_username" &&
info "Rename home directory from $target_home_path$variable_old_username to $target_home_path$target_username..." &&
mv -v "$target_home_path$variable_old_username" "$target_home_path$target_username" || error "Failed to rename home directory"
else
target_username="$default_username"
fi
target_user_home_folder_path="$target_home_path$target_username/" &&
target_user_ssh_folder_path="$target_user_home_folder_path"".ssh/" &&
target_authorized_keys="$target_user_ssh_folder_path""authorized_keys" &&
# Activate later. Here was a bug
question "Should the $target_username have sudo rights? (y/N):" && read -r sudo_decision
if [ "$sudo_decision" == "y" ]; then
sudo_config_dir="$root_mount_path""etc/sudoers.d/"
sudo_config_file="$sudo_config_dir$target_username"
mkdir -vp $sudo_config_dir
echo "$target_username ALL=(ALL:ALL) ALL" > "$sudo_config_file" || error "Failed to create sudoers file for $target_username"
chmod 440 "$sudo_config_file" || error "Failed to set permissions on sudoers file for $target_username"
fi
question "Enter the path to the SSH key to be added to the image (default: none):" && read -r origin_user_rsa_pub || error
if [ -z "$origin_user_rsa_pub" ]
then
info "Skipped SSH-key copying.."
else
if [ -f "$origin_user_rsa_pub" ]
then
info "Copy ssh key to target..."
mkdir -v "$target_user_ssh_folder_path" || warning "Folder \"$target_user_ssh_folder_path\" exists. Can't be created."
cat "$origin_user_rsa_pub" > "$target_authorized_keys" &&
target_authorized_keys_content=$(cat "$target_authorized_keys") &&
info "$target_authorized_keys contains the following: $target_authorized_keys_content" &&
info "Set permissions with chmod..." &&
chmod -v 700 "$target_user_ssh_folder_path" &&
chmod -v 600 "$target_authorized_keys" || error "Failed to set ownership and permissions on ssh folder"
else
error "The ssh key \"$origin_user_rsa_pub\" can't be copied to \"$target_authorized_keys\" because it doesn't exist."
fi
fi
info "Start chroot procedures..."
mount_chroot_binds
copy_qemu
copy_resolve_conf
chroot_user_home_path="/home/$target_username/"
chroot_user_ssh_folder_path="$chroot_user_home_path.ssh"
if [ "$rename_decision" == "y" ]; then
info "Delete old user and create new user" &&
(
echo "userdel -r $variable_old_username"
echo "useradd -m -d $chroot_user_home_path -s /bin/bash $target_username"
echo "chown -R $target_username:$target_username $chroot_user_home_path"
) | chroot "$root_mount_path" /bin/bash || error "Failed to delete old user and create new user"
fi
if [ -n "$origin_user_rsa_pub" ]
then
info "Chroot to set ownership..." &&
( echo "chown -vR $target_username:$target_username $chroot_user_ssh_folder_path" ) | chroot "$root_mount_path" /bin/bash || error
fi
question "Type in new password for user root and $target_username (leave empty to skip): " && read -r password_1
if [ -n "$password_1" ]; then
question "Repeat new password for \"$target_username\": " && read -r password_2
if [ "$password_1" = "$password_2" ]; then
info "Changing passwords on target system..."
(
echo "(
echo '$password_1'
echo '$password_1'
) | passwd $target_username"
echo "(
echo '$password_1'
echo '$password_1'
) | passwd"
) | chroot "$root_mount_path" /bin/bash || error "Failed to change password."
else
error "Passwords didn't match."
fi
else
info "No password change requested, skipped password change..."
fi
hostname_path="$root_mount_path/etc/hostname"
question "Type in the hostname (leave empty to skip): " && read -r target_hostname
if [ -n "$target_hostname" ]; then
echo "$target_hostname" > "$hostname_path" || error "Failed to set hostname."
else
target_hostname=$(cat "$hostname_path")
info "No hostname change requested, skipped hostname change..."
fi
info "Used hostname is: $target_hostname"
case "$distribution" in
"arch"|"manjaro")
info "Populating keys..." &&
(
echo "yes | pacman-key --init"
echo "yes | pacman-key --populate archlinuxarm"
) | chroot "$root_mount_path" /bin/bash || error
;;
esac
question "Should the system be updated?(y/N)" && read -r update_system
if [ "$update_system" == "y" ]
then
info "Updating system..."
case "$distribution" in
"arch"|"manjaro")
echo "pacman --noconfirm -Syyu" | chroot "$root_mount_path" /bin/bash || error
;;
"moode"|"retropie")
(
echo "yes | apt update"
echo "yes | apt upgrade"
) | chroot "$root_mount_path" /bin/bash || error
;;
*)
warning "System update for operation system \"$distribution\" is not supported yet. Skipped."
;;
esac
fi
info "Installing software for filesystem $root_filesystem..."
if [ "$root_filesystem" == "btrfs" ]
then
install "btrfs-progs"
else
info "Skipped."
fi
if [ "$encrypt_system" == "y" ]
then
# Adapted this instruction for setting up encrypted systems
# @see https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38
# @see https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994
info "Setup encryption..." &&
info "Installing neccessary software..." &&
install "$(get_packages "server/luks")" &&
dropbear_root_key_path="$root_mount_path""etc/dropbear/root_key" &&
info "Adding $target_authorized_keys to dropbear..." &&
cp -v "$target_authorized_keys" "$dropbear_root_key_path" &&
# Concerning mkinitcpio warning
# @see https://gist.github.com/imrvelj/c65cd5ca7f5505a65e59204f5a3f7a6d
mkinitcpio_path="$root_mount_path""etc/mkinitcpio.conf" &&
info "Configuring $mkinitcpio_path..." &&
mkinitcpio_search_modules="MODULES=()" || error
# Concerning which moduls to load
# @see https://raspberrypi.stackexchange.com/questions/67051/raspberry-pi-3-with-archarm-and-encrypted-disk-will-not-boot-how-can-be-identif
case "$raspberry_pi_version" in
"1" | "2")
mkinitcpio_additional_modules=""
;;
"3b")
mkinitcpio_additional_modules="smsc95xx"
;;
"3b+" | "4")
mkinitcpio_additional_modules="lan78xx"
;;
*)
warning "Version $raspberry_pi_version isn't supported."
;;
esac
mkinitcpio_replace_modules="MODULES=(g_cdc usb_f_acm usb_f_ecm $mkinitcpio_additional_modules g_ether)" || error
mkinitcpio_search_binaries="BINARIES=()" &&
mkinitcpio_replace_binaries=$(echo "BINARIES=(/usr/lib/libgcc_s.so.1)"| sed -e 's/[\/&]/\\&/g') &&
mkinitcpio_encrypt_hooks="sleep netconf dropbear encryptssh" &&
mkinitcpio_hooks_prefix="base udev autodetect microcode modconf kms keyboard keymap consolefont block"
mkinitcpio_hooks_suffix="filesystems fsck"
mkinitcpio_search_hooks="HOOKS=($mkinitcpio_hooks_prefix $mkinitcpio_hooks_suffix)" &&
mkinitcpio_replace_hooks="HOOKS=($mkinitcpio_hooks_prefix $mkinitcpio_encrypt_hooks $mkinitcpio_hooks_suffix)" &&
replace_in_file "$mkinitcpio_search_modules" "$mkinitcpio_replace_modules" "$mkinitcpio_path" &&
replace_in_file "$mkinitcpio_search_binaries" "$mkinitcpio_replace_binaries" "$mkinitcpio_path" &&
replace_in_file "$mkinitcpio_search_hooks" "$mkinitcpio_replace_hooks" "$mkinitcpio_path" &&
info "Content of $mkinitcpio_path:$(cat "$mkinitcpio_path")" &&
info "Generating mkinitcpio..." &&
echo "mkinitcpio -vP" | chroot "$root_mount_path" /bin/bash &&
fstab_insert_line="UUID=$root_partition_uuid / $root_filesystem defaults,noatime 0 1" &&
info "Configuring $fstab_path..." || error
if grep -q "$fstab_insert_line" "$fstab_path"
then
warning "$fstab_path contains allready $fstab_insert_line - Skipped."
else
echo "$fstab_insert_line" >> "$fstab_path" || error
fi
info "Content of $fstab_path:$(cat "$fstab_path")" &&
crypttab_path="$root_mount_path""etc/crypttab" &&
crypttab_insert_line="$root_mapper_name UUID=$root_partition_uuid none luks" &&
info "Configuring $crypttab_path..." || error
if grep -q "$crypttab_insert_line" "$crypttab_path"
then
warning "$crypttab_path contains allready $crypttab_insert_line - Skipped."
else
echo "$crypttab_insert_line" >> "$crypttab_path" || error
fi
info "Content of $crypttab_path:$(cat "$crypttab_path")" &&
boot_txt_path="$boot_mount_path""boot.txt" &&
cryptdevice_configuration="cryptdevice=UUID=$root_partition_uuid:$root_mapper_name root=$root_mapper_path" || error
if [ -f "$boot_txt_path" ];
then
info "Configuring $boot_txt_path..." &&
boot_txt_delete_line=$(echo "part uuid \${devtype} \${devnum}:2 uuid" | sed -e 's/[]\/$*.^[]/\\&/g') &&
boot_txt_setenv_origin=$(echo "setenv bootargs console=ttyS1,115200 console=tty0 root=PARTUUID=\${uuid} rw rootwait smsc95xx.macaddr=\"\${usbethaddr}\"" | sed -e 's/[]\/$*.^[]/\\&/g') &&
# Concerning issues with network adapter names;
# @see https://forum.iobroker.net/topic/40542/raspberry-pi4-kein-eth0-mehr/16
boot_txt_setenv_replace=$(echo "setenv bootargs console=ttyS1,115200 console=tty0 ip=::::$target_hostname:eth0:dhcp $cryptdevice_configuration rw rootwait smsc95xx.macaddr=\"\${usbethaddr}\" net.ifnames=0 biosdevname=0"| sed -e 's/[\/&]/\\&/g') &&
replace_in_file "$boot_txt_delete_line" "" "$boot_txt_path" &&
replace_in_file "$boot_txt_setenv_origin" "$boot_txt_setenv_replace" "$boot_txt_path" &&
info "Content of $boot_txt_path:$(cat "$boot_txt_path")" &&
info "Generating..." &&
echo "cd /boot/ && ./mkscr || exit 1" | chroot "$root_mount_path" /bin/bash || error
else
cmdline_txt_path="$boot_mount_path""cmdline.txt" &&
info "Configuring $cmdline_txt_path..." &&
cmdline_search_string=$(echo "root=/dev/mmcblk0p2" | sed -e 's/[\/&]/\\&/g') &&
cmdline_replace_string=$(echo "$cryptdevice_configuration rootfstype=$root_filesystem"| sed -e 's/[\/&]/\\&/g') &&
replace_in_file "$cmdline_search_string" "$cmdline_replace_string" "$cmdline_txt_path" &&
info "Content of $cmdline_txt_path:$(cat "$cmdline_txt_path")" || error
fi
fi
info "Running system specific procedures..."
if [ "$distribution" = "retropie" ]
then
if [ -n "$origin_user_rsa_pub" ]
then
ssh_file="$boot_mount_path""ssh" &&
echo "" > "$ssh_file"
fi
question "Should the RetroFlag specific procedures be executed?(y/N)" && read -r setup_retroflag
if [ "$setup_retroflag" == "y" ]
then
info "Executing RetroFlag specific procedures..." &&
(
echo 'wget -O - "https://raw.githubusercontent.com/RetroFlag/retroflag-picase/master/install_gpi.sh" | bash'
) | chroot "$root_mount_path" /bin/bash || error
fi
fi
fi
destructor
success "Setup successfull :)" && exit 0

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