diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a001cc5..d098ce8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,6 @@ jobs: - 'lib/atomic/common.sh' - 'extras/pacman-wrapper' - 'tests/test_harness.sh' - - 'tests/test_config.sh' - 'tests/test_validate.sh' - 'tests/test_device.sh' - 'tests/test_space.sh' @@ -52,8 +51,10 @@ jobs: - 'tests/test_rebuild_uki.sh' - '.github/workflows/ci.yml' python: + - 'lib/atomic/config.py' - 'lib/atomic/fstab.py' - 'lib/atomic/rootdev.py' + - 'tests/test_config.py' - 'tests/test_fstab.py' - 'tests/test_rootdev.py' - '.github/workflows/ci.yml' @@ -88,8 +89,8 @@ jobs: steps: - uses: actions/checkout@v6 - run: pip install --no-cache-dir ruff mypy - - run: ruff check lib/atomic/fstab.py lib/atomic/rootdev.py - - run: mypy --ignore-missing-imports lib/atomic/fstab.py lib/atomic/rootdev.py + - run: ruff check lib/atomic/config.py lib/atomic/fstab.py lib/atomic/rootdev.py + - run: mypy --ignore-missing-imports lib/atomic/config.py lib/atomic/fstab.py lib/atomic/rootdev.py python-test: needs: changes @@ -100,6 +101,15 @@ jobs: - run: pip install --no-cache-dir pytest - run: python -m pytest tests/ -v + python-test-root: + needs: changes + if: needs.changes.outputs.python == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - run: sudo pip install --no-cache-dir pytest + - run: sudo python -m pytest tests/test_config.py::TestParseConfig::test_config_not_owned_by_root -v + bash-test: needs: changes if: needs.changes.outputs.shell == 'true' @@ -108,7 +118,6 @@ jobs: - uses: actions/checkout@v6 - run: | for t in \ - tests/test_config.sh \ tests/test_validate.sh \ tests/test_device.sh \ tests/test_space.sh \ diff --git a/Makefile b/Makefile index edf6a80..d9ec7e6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install uninstall reinstall install-conf man clean test +.PHONY: install uninstall reinstall install-conf man clean test test-root PREFIX = /usr SYSCONFDIR = /etc @@ -29,7 +29,6 @@ clean: rm -f $(MANPAGES) UNIT_TESTS = \ - tests/test_config.sh \ tests/test_validate.sh \ tests/test_device.sh \ tests/test_space.sh \ @@ -54,6 +53,21 @@ test: bash tests/test_integration.sh python -m pytest tests/test_fstab.py -v python -m pytest tests/test_rootdev.py -v + python -m pytest tests/test_config.py -v + +ROOT_TESTS = \ + tests/test_config.py::TestParseConfig::test_config_not_owned_by_root + +test-root: + @if [ "$$(id -u)" -ne 0 ]; then \ + echo "SKIP: test-root requires root (run: sudo make test-root)"; \ + else \ + for t in $(ROOT_TESTS); do \ + echo ""; \ + echo "━━━ $$t ━━━"; \ + python -m pytest "$$t" -v || exit 1; \ + done; \ + fi install: install -Dm755 bin/atomic-upgrade $(DESTDIR)$(BINDIR)/atomic-upgrade @@ -62,6 +76,7 @@ install: install -Dm755 bin/atomic-rebuild-uki $(DESTDIR)$(BINDIR)/atomic-rebuild-uki install -Dm644 lib/atomic/common.sh $(DESTDIR)$(LIBDIR)/common.sh + install -Dm755 lib/atomic/config.py $(DESTDIR)$(LIBDIR)/config.py install -Dm755 lib/atomic/fstab.py $(DESTDIR)$(LIBDIR)/fstab.py install -Dm755 lib/atomic/rootdev.py $(DESTDIR)$(LIBDIR)/rootdev.py @@ -102,6 +117,7 @@ uninstall: rm -f $(DESTDIR)$(BINDIR)/atomic-gc rm -f $(DESTDIR)$(BINDIR)/atomic-guard rm -f $(DESTDIR)$(BINDIR)/atomic-rebuild-uki + rm -f $(DESTDIR)$(LIBDIR)/config.py rm -rf $(DESTDIR)$(LIBDIR)/ rm -f $(DESTDIR)$(ZSH_COMPDIR)/_atomic-gc rm -f $(DESTDIR)$(ZSH_COMPDIR)/_atomic-rebuild-uki diff --git a/README.md b/README.md index c76b526..7daf3e7 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Edit `/etc/atomic.conf`: | `MAPPER_NAME` | `root_crypt` | dm-crypt mapper name (fallback if auto-detection fails) | | `KERNEL_PKG` | `linux` | Kernel package (linux/linux-lts/linux-zen) | | `KERNEL_PARAMS` | *(security defaults)* | Kernel command line parameters | -| `CHROOT_COMMAND` | *(none)* | Default command in snapshot chroot (overrides built-in `pacman -Syu`) | +| `CHROOT_COMMAND` | `/usr/bin/pacman -Syu` | Default command in snapshot chroot (overrides built-in `pacman -Syu`) | | `SBCTL_SIGN` | `0` | Sign UKI files with sbctl for Secure Boot (`0`=off, `1`=on) | | `UPGRADE_GUARD` | `1` | Upgrade guard: block direct `pacman -Syu` (`0`=off, `1`=on) | | `HOME_COPY_FILES` | *(empty)* | Files to copy into isolated home subvolumes (see [Home isolation](#home-isolation)) | @@ -336,6 +336,7 @@ If the ESP is not mounted during the orphan sweep phase, it is skipped with a wa | `atomic-guard` | Pacman hook — blocks direct `-Syu`, allows installs/removes | | `atomic-rebuild-uki` | Rebuild UKI for existing snapshot | | `common.sh` | Shared library (config, locking, btrfs, UKI build, GC, home skeleton) | +| `config.py` | Config file parser with proper quote handling via `shlex` | | `fstab.py` | Safe fstab editing (atomic write + verification + rollback) | | `rootdev.py` | Auto-detect root device type (LUKS/LVM/plain) and build kernel cmdline | | `pacman-wrapper` | Optional `/usr/local/bin/pacman` wrapper | diff --git a/bin/atomic-upgrade b/bin/atomic-upgrade index 1e3c163..a867e45 100644 --- a/bin/atomic-upgrade +++ b/bin/atomic-upgrade @@ -8,7 +8,10 @@ set -euo pipefail readonly LIBDIR="/usr/lib/atomic" -_src() { local p; p=$(verify-lib "$1" "$LIBDIR/") && source "$p" || exit 1; } + +_src() { + local p; p=$(verify-lib "$1" "$LIBDIR/") && source "$p" || exit 1 +} # Source common.sh early for VERSION (skip auto-init for fast --help/--version) _ATOMIC_NO_INIT=1 @@ -107,17 +110,25 @@ if [[ -n "$CUSTOM_TAG" ]]; then fi # Default chroot command: use CHROOT_COMMAND from config (or default in common.sh). -# Note: CHROOT_COMMAND uses simple word splitting; args with spaces inside -# are not supported (same limitation as HOME_COPY_FILES). if [[ ${#CHROOT_CMD[@]} -eq 0 ]]; then [[ -z "${CHROOT_COMMAND:-}" ]] && CHROOT_COMMAND="/usr/bin/pacman -Syu" - read -ra CHROOT_CMD <<< "$CHROOT_COMMAND" -fi + CHROOT_CMD=() + # Capture null-delimited output via temp file to reliably detect failures. + # Use _ATOMIC_LIB_DIR if available (set by common.sh), else fall back to LIBDIR. + _tmp_array=$(mktemp) + _cfg_py="${_ATOMIC_LIB_DIR:-${LIBDIR}}/config.py" + if CONFIG_FILE="${CONFIG_FILE:-/etc/atomic.conf}" python3 "${_cfg_py}" array CHROOT_COMMAND > "$_tmp_array" 2>/dev/null; then + while IFS= read -r -d '' token; do + CHROOT_CMD+=("$token") + done < "$_tmp_array" + else + echo "WARN: config.py failed, falling back to word-split" >&2 + fi + rm -f "$_tmp_array" -# Warn if CHROOT_COMMAND contains quotes (word splitting doesn't process them) -if [[ "$CHROOT_COMMAND" =~ [\'\"] ]]; then - echo "WARN: CHROOT_COMMAND contains quotes, but word splitting doesn't process them." >&2 - echo "WARN: Use CLI instead: atomic-upgrade -- pacman -S 'nvidia dkms'" >&2 + if [[ ${#CHROOT_CMD[@]} -eq 0 ]]; then + read -ra CHROOT_CMD <<< "$CHROOT_COMMAND" + fi fi # Verify required variables are set @@ -166,6 +177,7 @@ cleanup() { mountpoint -q "$BTRFS_MOUNT" 2>/dev/null && umount "$BTRFS_MOUNT" [[ -n "${LOCK_FD:-}" ]] && exec {LOCK_FD}>&- 2>/dev/null + [[ -n "${_tmp_array:-}" ]] && rm -f "$_tmp_array" 2>/dev/null return $exit_code } diff --git a/lib/atomic/common.sh b/lib/atomic/common.sh index e85119c..b637a13 100644 --- a/lib/atomic/common.sh +++ b/lib/atomic/common.sh @@ -4,11 +4,7 @@ # Shared functions and configuration for the atomic-upgrade system. # Sourced by: atomic-upgrade, atomic-gc, atomic-rebuild-uki, atomic-guard -# ── Library directory ────────────────────────────────────────────── - -if [[ -z "${LIBDIR:-}" ]]; then - LIBDIR="/usr/lib/atomic" -fi +_ATOMIC_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ── Project version ──────────────────────────────────────────────── @@ -32,62 +28,25 @@ KERNEL_PARAMS="rw slab_nomerge init_on_alloc=1 page_alloc.shuffle=1 pti=on vsysc # Default chroot command (overridden by config or CLI -- CHROOT_COMMAND...) CHROOT_COMMAND="/usr/bin/pacman -Syu" -# ── Config loading (safe parser, no arbitrary code execution) ─────── +# ── Config loading (delegates to Python for proper quote handling) ─────── CONFIG_FILE="${CONFIG_FILE:-/etc/atomic.conf}" load_config() { [[ -f "$CONFIG_FILE" ]] || return 0 - # Refuse to load config not owned by root - local owner - owner=$(stat -c %u "$CONFIG_FILE" 2>/dev/null) - if [[ "$owner" != "0" ]]; then - echo "ERROR: $CONFIG_FILE not owned by root (owner uid: $owner)" >&2 + local shell_output config_err + if ! shell_output=$(CONFIG_FILE="${CONFIG_FILE}" python3 "${_ATOMIC_LIB_DIR}/config.py" shell 2>/dev/null); then + config_err=$(CONFIG_FILE="${CONFIG_FILE}" python3 "${_ATOMIC_LIB_DIR}/config.py" shell 2>&1) || true + echo "ERROR: Failed to parse config with config.py" >&2 + echo "ERROR: Details: ${config_err:-unknown}" >&2 return 1 fi - # Whitelist of allowed config keys - local -a allowed=(BTRFS_MOUNT NEW_ROOT ESP KEEP_GENERATIONS MAPPER_NAME KERNEL_PARAMS KERNEL_PKG CHROOT_COMMAND SBCTL_SIGN UPGRADE_GUARD HOME_COPY_FILES) - while IFS='=' read -r key value; do - # Strip leading/trailing whitespace from key - key="${key#"${key%%[![:space:]]*}"}" - key="${key%"${key##*[![:space:]]}"}" - # Trim leading/trailing whitespace from value - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - - # Strip inline comments: everything after ' #' (space-hash). - # Limitation: values containing literal ' #' are truncated. - # Quoted values do not protect against this. For kernel params - # and paths used in this config, this is not a practical concern. - value="${value%% #*}" - value="${value%"${value##*[![:space:]]}"}" - - # Skip comments and blank lines - [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue - - # Check against whitelist - local valid=0 - for a in "${allowed[@]}"; do - if [[ "$key" == "$a" ]]; then - valid=1 - break - fi - done - - if [[ $valid -eq 1 ]]; then - # Strip surrounding quotes (single or double) - value="${value#\"}" - value="${value%\"}" - value="${value#\'}" - value="${value%\'}" - printf -v "$key" '%s' "$value" - else - echo "WARN: Unknown config key ignored: $key" >&2 - fi - done < "$CONFIG_FILE" + [[ -z "$key" || "$key" == \#* ]] && continue + printf -v "$key" '%s' "$value" + done <<< "$shell_output" } # ── Auto-initialization ────────────────────────────────────────────── @@ -132,7 +91,7 @@ check_dependencies() { local root_type root_type=$(python3 -c " import json, importlib.util, sys -spec = importlib.util.spec_from_file_location('rootdev', '${LIBDIR}/rootdev.py') +spec = importlib.util.spec_from_file_location('rootdev', '${_ATOMIC_LIB_DIR}/rootdev.py') mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) try: @@ -150,7 +109,7 @@ except Exception: return 1 fi # Verify python helper modules exist - for helper in "${LIBDIR}/fstab.py" "${LIBDIR}/rootdev.py"; do + for helper in "${_ATOMIC_LIB_DIR}/fstab.py" "${_ATOMIC_LIB_DIR}/rootdev.py"; do [[ -f "$helper" ]] || { echo "ERROR: Missing helper: $helper" >&2 return 1 @@ -280,7 +239,7 @@ verify_uki() { # ── fstab update (delegates to Python for safety) ────────────────── update_fstab() { - python3 "${LIBDIR}/fstab.py" "$@" + python3 "${_ATOMIC_LIB_DIR}/fstab.py" "$@" } # ── fstab /home update ────────────────────────────────────────── @@ -288,7 +247,7 @@ update_fstab() { # Args: $1 = fstab path, $2 = new home subvolume name update_fstab_home() { - python3 "${LIBDIR}/fstab.py" home "$@" + python3 "${_ATOMIC_LIB_DIR}/fstab.py" home "$@" } # ── Home skeleton population ──────────────────────────────────── @@ -394,7 +353,7 @@ get_root_device() { fi local dev - dev=$(python3 "${LIBDIR}/rootdev.py" device 2>/dev/null) + dev=$(python3 "${_ATOMIC_LIB_DIR}/rootdev.py" device 2>/dev/null) if [[ -n "$dev" && -e "$dev" ]]; then _ROOT_DEVICE="$dev" echo "$dev" @@ -572,7 +531,7 @@ build_uki() { fi local root_cmdline - root_cmdline=$(python3 "${LIBDIR}/rootdev.py" cmdline "$new_subvol") || { + root_cmdline=$(python3 "${_ATOMIC_LIB_DIR}/rootdev.py" cmdline "$new_subvol") || { echo "ERROR: Cannot detect root device for cmdline" >&2 return 1 } diff --git a/lib/atomic/config.py b/lib/atomic/config.py new file mode 100644 index 0000000..7288174 --- /dev/null +++ b/lib/atomic/config.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +/usr/lib/atomic/config.py + +Parse /etc/atomic.conf with proper shell-style quote handling. +Strips outer quotes (single/double) and handles inline comments. +Uses shlex only in config_to_array() for safe tokenization. + +Usage: + config.py -> dump all config as JSON + config.py KEY -> print value of KEY + config.py validate -> validate config, exit 0/1 +""" + +import json +import shlex +import sys +import os +from pathlib import Path + +DEFAULT_CONFIG = { + "BTRFS_MOUNT": "/run/atomic/temp_root", + "NEW_ROOT": "/run/atomic/newroot", + "ESP": "/efi", + "KEEP_GENERATIONS": "3", + "MAPPER_NAME": "root_crypt", + "KERNEL_PKG": "linux", + "KERNEL_PARAMS": "rw slab_nomerge init_on_alloc=1 page_alloc.shuffle=1 pti=on vsyscall=none randomize_kstack_offset=on debugfs=off", + "CHROOT_COMMAND": "/usr/bin/pacman -Syu", + "SBCTL_SIGN": "0", + "UPGRADE_GUARD": "1", + "HOME_COPY_FILES": "", +} + +ALLOWED_KEYS = set(DEFAULT_CONFIG.keys()) + + +def parse_config(path=None): + """Parse atomic.conf with proper quote handling. + + Strips outer quotes (single/double) and handles inline comments. + Uses shlex only in config_to_array() for safe tokenization. + """ + config = dict(DEFAULT_CONFIG) + if path is None: + path = os.environ.get("CONFIG_FILE", "/etc/atomic.conf") + config_path = Path(path) + if not config_path.exists(): + return config + + # Only check owner for /etc/atomic.conf (system config). + # Resolve symlinks so that CONFIG_FILE set to a symlink pointing + # to /etc/atomic.conf is still correctly validated. + # Other paths (e.g. test configs) are allowed to have any owner. + if config_path.resolve() == Path("/etc/atomic.conf"): + try: + if config_path.resolve().stat().st_uid != 0: + print(f"ERROR: {config_path.resolve()} not owned by root", file=sys.stderr) + sys.exit(1) + except OSError as e: + print(f"ERROR: Cannot read {path}: {e}", file=sys.stderr) + sys.exit(1) + + content = config_path.read_text() + + for line in content.splitlines(): + line = line.strip() + + if not line or line.startswith("#"): + continue + + if " #" in line: + line = line[:line.index(" #")].strip() + + if not line or line.startswith("#"): + continue + + if "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + + if key not in ALLOWED_KEYS: + print(f"WARN: Unknown config key ignored: {key}", file=sys.stderr) + continue + + if len(value) >= 2: + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + config[key] = value + + return config + + +def config_to_shell(config): + """Convert config dict to shell-safe KEY=value lines.""" + lines = [] + for key, value in config.items(): + lines.append(f"{key}={value}") + return "\n".join(lines) + + +def config_to_array(config, key): + """Output array elements null-separated for safe shell reading.""" + value = config.get(key, "") + if not value: + return + tokens = shlex.split(value, posix=True) + for token in tokens: + sys.stdout.write(token + '\0') + + +def main(): + command = sys.argv[1] if len(sys.argv) > 1 else "dump" + + if command == "dump": + config = parse_config() + json.dump(config, sys.stdout, indent=2) + print() + return 0 + + if command == "validate": + config = parse_config() + numeric_keys = ["KEEP_GENERATIONS", "SBCTL_SIGN", "UPGRADE_GUARD"] + for key in numeric_keys: + try: + int(config[key]) + except ValueError: + print(f"ERROR: {key} must be a number", file=sys.stderr) + return 1 + print("Config valid") + return 0 + + if command == "shell": + config = parse_config() + print(config_to_shell(config)) + return 0 + + if command == "array": + if len(sys.argv) < 3: + print("ERROR: array command requires KEY argument", file=sys.stderr) + return 1 + key = sys.argv[2] + config = parse_config() + config_to_array(config, key) + return 0 + + key = command + config = parse_config() + if key not in config: + print(f"ERROR: Unknown key: {key}", file=sys.stderr) + return 1 + + print(config[key]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/man/atomic-upgrade.8 b/man/atomic-upgrade.8 index c319b11..2977ed7 100644 --- a/man/atomic-upgrade.8 +++ b/man/atomic-upgrade.8 @@ -1,4 +1,4 @@ -.\" Automatically generated by Pandoc 3.5 +.\" Automatically generated by Pandoc 3.6 .\" .TH "ATOMIC\-UPGRADE" "8" "" "atomic\-upgrade" "System Administration" .SH NAME @@ -167,6 +167,9 @@ See \f[B]atomic.conf\f[R](5). \f[I]/usr/lib/atomic/common.sh\f[R] Shared shell library. .TP +\f[I]/usr/lib/atomic/config.py\f[R] +Config file parser with proper quote handling via \f[CR]shlex\f[R]. +.TP \f[I]/usr/lib/atomic/fstab.py\f[R] Safe fstab manipulation (atomic write, verification, rollback). .TP diff --git a/man/atomic-upgrade.8.md b/man/atomic-upgrade.8.md index 02dddb8..f8f32f7 100644 --- a/man/atomic-upgrade.8.md +++ b/man/atomic-upgrade.8.md @@ -156,6 +156,9 @@ If space cannot be determined, a warning is shown and the operation proceeds. */usr/lib/atomic/common.sh* : Shared shell library. +*/usr/lib/atomic/config.py* +: Config file parser with proper quote handling via `shlex`. + */usr/lib/atomic/fstab.py* : Safe fstab manipulation (atomic write, verification, rollback). diff --git a/man/atomic.conf.5 b/man/atomic.conf.5 index 1713c64..961bd62 100644 --- a/man/atomic.conf.5 +++ b/man/atomic.conf.5 @@ -1,4 +1,4 @@ -.\" Automatically generated by Pandoc 3.5 +.\" Automatically generated by Pandoc 3.6 .\" .TH "ATOMIC.CONF" "5" "" "atomic\-upgrade" "File Formats" .SH NAME @@ -26,10 +26,10 @@ Values containing a literal \f[CR]#\f[R] sequence will be truncated at that point. This does not affect typical paths or kernel parameters. .PP -Only whitelisted keys are accepted. +Only allowed keys are accepted. Unknown keys produce a warning on stderr and are ignored. -The file must be owned by root (uid 0); otherwise it is rejected -entirely. +The file must be owned by root (uid 0) when at +\f[CR]/etc/atomic.conf\f[R]; otherwise it is rejected entirely. .SH OPTIONS .TP \f[B]BTRFS_MOUNT\f[R] @@ -71,14 +71,14 @@ device arguments. Default: \f[B]rw slab_nomerge init_on_alloc=1 page_alloc.shuffle=1 pti=on vsyscall=none randomize_kstack_offset=on debugfs=off\f[R]. .TP -\f[B]COMMAND\f[R] +\f[B]CHROOT_COMMAND\f[R] Default command to run in the snapshot chroot, overriding the built\-in default of \f[B]pacman \-Syu\f[R]. -Command\-line \f[B]\-\-\f[R] \f[I]COMMAND...\f[R] takes priority over +Command\-line \f[B]-- CHROOT_COMMAND\&...\f[R] takes priority over this setting. -Note: arguments with spaces inside are not supported due to simple word -splitting. -Default: \f[I](none)\f[R]. +Arguments with spaces are supported via proper shell\-style quote +handling. +Default: \f[I]/usr/bin/pacman \-Syu\f[R]. .TP \f[B]SBCTL_SIGN\f[R] Enable UKI signing with \f[B]sbctl\f[R](8) for Secure Boot. @@ -101,7 +101,8 @@ value per invocation. Paths with spaces are not supported. Default: \f[I](empty)\f[R]. .SH SECURITY -The configuration file must be owned by root. +The configuration file must be owned by root when at +\f[CR]/etc/atomic.conf\f[R]. If the file is owned by another user, it is rejected and no values are loaded. .PP diff --git a/man/atomic.conf.5.md b/man/atomic.conf.5.md index 66dc31f..de497b0 100644 --- a/man/atomic.conf.5.md +++ b/man/atomic.conf.5.md @@ -29,9 +29,9 @@ single or double quotes, which are stripped during parsing. occurrence. Values containing a literal ` #` sequence will be truncated at that point. This does not affect typical paths or kernel parameters. -Only whitelisted keys are accepted. Unknown keys produce a warning on stderr -and are ignored. The file must be owned by root (uid 0); otherwise it is -rejected entirely. +Only allowed keys are accepted. Unknown keys produce a warning on stderr +and are ignored. The file must be owned by root (uid 0) when at +`/etc/atomic.conf`; otherwise it is rejected entirely. # OPTIONS @@ -69,8 +69,8 @@ rejected entirely. **CHROOT_COMMAND** : Default command to run in the snapshot chroot, overriding the built-in default of **pacman -Syu**. Command-line **-- CHROOT_COMMAND...** takes priority - over this setting. Note: arguments with spaces inside are not supported - due to simple word splitting. Default: *(none)*. + over this setting. Arguments with spaces are supported via proper + shell-style quote handling. Default: */usr/bin/pacman -Syu*. **SBCTL_SIGN** : Enable UKI signing with **sbctl**(8) for Secure Boot. Set to **1** to @@ -90,8 +90,8 @@ rejected entirely. # SECURITY -The configuration file must be owned by root. If the file is owned by -another user, it is rejected and no values are loaded. +The configuration file must be owned by root when at `/etc/atomic.conf`. +If the file is owned by another user, it is rejected and no values are loaded. Only the keys listed above are accepted. Attempts to set arbitrary shell variables (e.g., **PATH**, **LD_PRELOAD**) via the config file are silently diff --git a/tests/README.md b/tests/README.md index 7a76592..9d39446 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,7 +4,7 @@ | File | Language | Framework | What it tests | |------|----------|-----------|---------------| -| `test_config.sh` | Bash | Custom assertions | Config loading, parsing, quoting, inline comments, ownership check, unknown key rejection, whitelist security, UPGRADE_GUARD, KERNEL_PARAMS, HOME_COPY_FILES, key whitespace trimming | +| `test_config.py` | Python | pytest | `config.py` — parse_config (quoting, inline comments, unknown keys, ownership), key lookup, validate, shell output (format + escaping), array output (simple + quoted spaces) | | `test_validate.sh` | Bash | Custom assertions | Function definitions, `validate_config` (numeric checks, path conflicts, ESP mount), `check_dependencies` (missing commands, LUKS→cryptsetup, SBCTL_SIGN→sbctl), `is_child_of_aur_helper` | | `test_device.sh` | Bash | Custom assertions | `get_current_subvol`/`get_current_subvol_raw` with various findmnt outputs, `get_root_device` (python helper, caching, fallback), `ensure_btrfs_mounted`, `validate_subvolume` | | `test_space.sh` | Bash | Custom assertions | `check_esp_space` (sufficient/low/df-failure), `check_btrfs_space` (native/df-fallback/both-fail, absolute minimum threshold, non-numeric btrfs output) | @@ -27,7 +27,7 @@ make test # Individual suites -bash tests/test_config.sh +python -m pytest tests/test_config.py -v bash tests/test_validate.sh bash tests/test_device.sh bash tests/test_space.sh @@ -44,7 +44,7 @@ python -m pytest tests/test_rootdev.py -v ## How they work -### Bash unit tests (`test_config.sh` .. `test_rebuild_uki.sh`) +### Bash unit tests (`test_validate.sh` .. `test_rebuild_uki.sh`) All unit test files source `test_harness.sh`, which provides: diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..82936ec --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import tempfile +from pathlib import Path +import pytest + +LIBDIR = Path(__file__).parent.parent / "lib" / "atomic" +CONFIG_SCRIPT = LIBDIR / "config.py" + + +@pytest.fixture +def temp_config(tmp_path): + """Create a temporary config file and yield its path.""" + config_path = tmp_path / "atomic.conf" + yield str(config_path) + + +def run_config(*args, config_path=None): + env = os.environ.copy() + if config_path is not None: + env['CONFIG_FILE'] = config_path + result = subprocess.run( + ["python3", str(CONFIG_SCRIPT)] + list(args), + capture_output=True, + text=True, + timeout=10, + env=env, + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + + +def create_temp_config(path, content, owner_uid=0): + with open(path, "w") as f: + f.write(content) + os.chmod(path, 0o644) + if owner_uid != 0: + try: + os.chown(path, owner_uid, -1) + except PermissionError: + pass + return path + + +class TestParseConfig: + def test_defaults_without_file(self, temp_config): + code, stdout, stderr = run_config("dump", config_path=temp_config) + assert code == 0 + data = json.loads(stdout) + assert data["BTRFS_MOUNT"] == "/run/atomic/temp_root" + assert data["KEEP_GENERATIONS"] == "3" + assert data["CHROOT_COMMAND"] == "/usr/bin/pacman -Syu" + + def test_simple_key_value(self, temp_config): + config_path = temp_config + create_temp_config(config_path, "BTRFS_MOUNT=/custom/mount\n") + code, stdout, stderr = run_config("dump", config_path=config_path) + assert code == 0 + data = json.loads(stdout) + assert data["BTRFS_MOUNT"] == "/custom/mount" + + def test_quoted_value_single(self, temp_config): + config_path = temp_config + create_temp_config(config_path, "CHROOT_COMMAND='/usr/bin/pacman -Syu'\n") + code, stdout, stderr = run_config("dump", config_path=config_path) + assert code == 0 + data = json.loads(stdout) + assert data["CHROOT_COMMAND"] == "/usr/bin/pacman -Syu" + + def test_quoted_value_double(self, temp_config): + config_path = temp_config + create_temp_config(config_path, 'CHROOT_COMMAND="/usr/bin/pacman -Syu"\n') + code, stdout, stderr = run_config("dump", config_path=config_path) + assert code == 0 + data = json.loads(stdout) + assert data["CHROOT_COMMAND"] == "/usr/bin/pacman -Syu" + + def test_inline_comment(self, temp_config): + config_path = temp_config + create_temp_config(config_path, "ESP=/efi # this is a comment\n") + code, stdout, stderr = run_config("dump", config_path=config_path) + assert code == 0 + data = json.loads(stdout) + assert data["ESP"] == "/efi" + + def test_shlex_quote_handling(self, temp_config): + config_path = temp_config + create_temp_config(config_path, 'CHROOT_COMMAND=pacman -S "package with spaces"\n') + code, stdout, stderr = run_config("dump", config_path=config_path) + assert code == 0 + data = json.loads(stdout) + assert data["CHROOT_COMMAND"] == 'pacman -S "package with spaces"' + + def test_unknown_key_ignored(self, temp_config): + config_path = temp_config + create_temp_config(config_path, "UNKNOWN_KEY=value\n") + code, stdout, stderr = run_config("dump", config_path=config_path) + assert code == 0 + assert "WARN" in stderr + data = json.loads(stdout) + assert "UNKNOWN_KEY" not in data + + def test_comment_lines_skipped(self, temp_config): + config_path = temp_config + create_temp_config(config_path, "# This is a comment\nESP=/boot/efi\n") + code, stdout, stderr = run_config("dump", config_path=config_path) + assert code == 0 + data = json.loads(stdout) + assert data["ESP"] == "/boot/efi" + + @pytest.mark.skipif(os.geteuid() != 0, reason="requires root") + def test_config_not_owned_by_root(self, tmp_path): + # Ownership check only triggers for /etc/atomic.conf. + # Use a real path in /etc via bind-mount over tmpfs so no real file is modified. + real_path = Path("/etc/atomic.conf") + # Ensure mount point exists before bind-mount + real_path.touch(exist_ok=True) + mount_point = tmp_path / "etc_atomic_conf" + mount_point.write_text("ESP=/efi\n") + # Bind-mount tmp file over /etc/atomic.conf + import subprocess as sp + sp.run(["mount", "--bind", str(mount_point), str(real_path)], check=True) + try: + os.chown(str(real_path), 1000, -1) + code, stdout, stderr = run_config("dump", config_path=str(real_path)) + assert code == 1 + assert "not owned by root" in stderr + finally: + result = sp.run(["umount", str(real_path)], capture_output=True) + if result.returncode != 0: + import sys + sys.stderr.write( + f"WARN: umount {real_path} failed (rc={result.returncode}): " + f"{result.stderr.decode('utf-8', 'replace')}\n" + ) + + +class TestKeyLookup: + def test_valid_key(self): + code, stdout, stderr = run_config("CHROOT_COMMAND") + assert code == 0 + assert "pacman" in stdout + + def test_invalid_key(self): + code, stdout, stderr = run_config("NONEXISTENT") + assert code == 1 + assert "ERROR" in stderr + + +class TestValidate: + def test_valid_config(self): + code, stdout, stderr = run_config("validate") + assert code == 0 + assert "Config valid" in stdout + + def test_invalid_keep_generations(self, temp_config): + config_path = temp_config + create_temp_config(config_path, "KEEP_GENERATIONS=abc\n") + code, stdout, stderr = run_config("validate", config_path=config_path) + assert code == 1 + + +class TestShellOutput: + def test_shell_output_format(self): + code, stdout, stderr = run_config("shell") + assert code == 0 + lines = stdout.split("\n") + assert any(line.startswith("BTRFS_MOUNT=") for line in lines) + assert any(line.startswith("KEEP_GENERATIONS=") for line in lines) + + def test_shell_output_escapes_spaces(self, temp_config): + config_path = temp_config + create_temp_config(config_path, 'CHROOT_COMMAND=pacman -S "package with spaces"\n') + code, stdout, stderr = run_config("shell", config_path=config_path) + assert code == 0 + lines = stdout.split("\n") + cmd_line = [line for line in lines if line.startswith("CHROOT_COMMAND=")][0] + assert "pacman" in cmd_line + + +class TestArrayOutput: + def test_array_simple(self, temp_config): + config_path = temp_config + create_temp_config(config_path, "CHROOT_COMMAND=/usr/bin/pacman -Syu\n") + code, stdout, stderr = run_config("array", "CHROOT_COMMAND", config_path=config_path) + assert code == 0 + tokens = stdout.split("\0") + assert "pacman" in tokens[0] + + def test_array_with_quoted_spaces(self, temp_config): + config_path = temp_config + create_temp_config(config_path, 'CHROOT_COMMAND=pacman -S "package with spaces"\n') + code, stdout, stderr = run_config("array", "CHROOT_COMMAND", config_path=config_path) + assert code == 0 + tokens = [t for t in stdout.split("\0") if t] + assert "package with spaces" in tokens + + +class TestDefaultConfig: + def test_owner_check_skipped_for_non_system_paths(self, temp_config): + """Ownership check should be skipped for paths other than /etc/atomic.conf.""" + config_path = temp_config + create_temp_config(config_path, "ESP=/test\n", owner_uid=1000) + code, stdout, stderr = run_config("dump", config_path=config_path) + assert code == 0 + data = json.loads(stdout) + assert data["ESP"] == "/test" diff --git a/tests/test_config.sh b/tests/test_config.sh deleted file mode 100644 index f8444f8..0000000 --- a/tests/test_config.sh +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env bash -# tests/test_config.sh — Config loading, parsing, security -# Run: bash tests/test_config.sh - -source "$(dirname "${BASH_SOURCE[0]}")/test_harness.sh" - - -# ── Default values after sourcing ──────────────────────────── - -section "Default values" - -assert_eq "BTRFS_MOUNT default" "/run/atomic/temp_root" "$BTRFS_MOUNT" -assert_eq "NEW_ROOT default" "/run/atomic/newroot" "$NEW_ROOT" -assert_eq "ESP default" "/efi" "$ESP" -assert_eq "KEEP_GENERATIONS default" "3" "$KEEP_GENERATIONS" -assert_eq "MAPPER_NAME default" "root_crypt" "$MAPPER_NAME" -assert_eq "KERNEL_PKG default" "linux" "$KERNEL_PKG" -assert_eq "LOCK_DIR default" "/run/atomic" "$LOCK_DIR" -assert_eq "LOCK_FILE default" "/run/atomic/atomic-upgrade.lock" "$LOCK_FILE" -assert_eq "SBCTL_SIGN default" "0" "$SBCTL_SIGN" -assert_eq "UPGRADE_GUARD default" "1" "$UPGRADE_GUARD" -assert_eq "HOME_COPY_FILES default" "" "$HOME_COPY_FILES" -assert_match "KERNEL_PARAMS contains rw" "rw" "$KERNEL_PARAMS" -assert_match "KERNEL_PARAMS contains pti=on" "pti=on" "$KERNEL_PARAMS" - - -# ── load_config() ─────────────────────────────────────────── - -section "load_config" - -# Test: config file does not exist → success (no-op) -CONFIG_FILE="${TESTDIR}/no_such_file.conf" -assert_rc "missing config file → rc 0" 0 load_config - -# Test: valid config file — direct call so side effects -# (variable assignments) are visible in the parent shell -CONFIG_FILE="${TESTDIR}/good.conf" -cat > "$CONFIG_FILE" <<'EOF' -# This is a comment -KEEP_GENERATIONS=5 -ESP=/boot/efi -SBCTL_SIGN=1 -KERNEL_PKG=linux-zen -EOF -KEEP_GENERATIONS=3; ESP="/efi"; SBCTL_SIGN=0; KERNEL_PKG="linux" -load_config -assert_eq "KEEP_GENERATIONS loaded" "5" "$KEEP_GENERATIONS" -assert_eq "ESP loaded" "/boot/efi" "$ESP" -assert_eq "SBCTL_SIGN loaded" "1" "$SBCTL_SIGN" -assert_eq "KERNEL_PKG loaded" "linux-zen" "$KERNEL_PKG" - -# Restore defaults for subsequent tests -KEEP_GENERATIONS=3; ESP="/efi"; SBCTL_SIGN=0; KERNEL_PKG="linux" - -# Test: quoted values — both single and double quotes stripped -CONFIG_FILE="${TESTDIR}/quoted.conf" -cat > "$CONFIG_FILE" <<'EOF' -ESP="/boot/efi" -KERNEL_PKG='linux-lts' -EOF -load_config -assert_eq "double-quoted value" "/boot/efi" "$ESP" -assert_eq "single-quoted value" "linux-lts" "$KERNEL_PKG" -ESP="/efi"; KERNEL_PKG="linux" - -# Test: inline comments stripped -CONFIG_FILE="${TESTDIR}/inline.conf" -cat > "$CONFIG_FILE" <<'EOF' -KEEP_GENERATIONS=7 # keep seven -EOF -KEEP_GENERATIONS=3 -load_config -assert_eq "inline comment stripped" "7" "$KEEP_GENERATIONS" -KEEP_GENERATIONS=3 - -# Test: value containing # without leading space — must NOT be stripped -CONFIG_FILE="${TESTDIR}/hash_in_value.conf" -cat > "$CONFIG_FILE" <<'EOF' -KERNEL_PARAMS=rw console=ttyS0,115200n8#1 -EOF -KERNEL_PARAMS="default" -load_config -assert_eq "hash without space preserved" "rw console=ttyS0,115200n8#1" "$KERNEL_PARAMS" -KERNEL_PARAMS="rw slab_nomerge init_on_alloc=1 page_alloc.shuffle=1 pti=on vsyscall=none randomize_kstack_offset=on debugfs=off" - -# Test: ' #' inside value is treated as inline comment (documented limitation) -CONFIG_FILE="${TESTDIR}/space_hash_in_value.conf" -cat > "$CONFIG_FILE" <<'EOF' -KERNEL_PARAMS=rw console=ttyS0 #debug -EOF -KERNEL_PARAMS="default" -load_config -assert_eq "space-hash treated as comment (documented)" "rw console=ttyS0" "$KERNEL_PARAMS" -KERNEL_PARAMS="rw slab_nomerge init_on_alloc=1 page_alloc.shuffle=1 pti=on vsyscall=none randomize_kstack_offset=on debugfs=off" - -# Test: unknown key → warning on stderr, known keys still applied. -# Direct call for side effects; capture stderr to file. -CONFIG_FILE="${TESTDIR}/unknown.conf" -cat > "$CONFIG_FILE" <<'EOF' -EVIL_KEY=hacked -KEEP_GENERATIONS=2 -EOF -KEEP_GENERATIONS=3 -load_config 2>"${TESTDIR}/unknown_stderr.txt" -assert_eq "known key still works with unknown present" "2" "$KEEP_GENERATIONS" -_captured=$(cat "${TESTDIR}/unknown_stderr.txt") -assert_contains "warns about unknown key" "Unknown config key" "$_captured" -KEEP_GENERATIONS=3 - -# Test: config not owned by root → error, variables unchanged. -# run_cmd (subshell) is safe here: we verify value did NOT change. -CONFIG_FILE="${TESTDIR}/badowner.conf" -cat > "$CONFIG_FILE" <<'EOF' -KEEP_GENERATIONS=9 -EOF -make_mock stat " -if [[ \"\${1:-}\" == \"-c\" && \"\${2:-}\" == \"%u\" ]]; then - echo \"1000\" -else - exec \"${REAL_STAT}\" \"\$@\" -fi -" -KEEP_GENERATIONS=3 -run_cmd load_config -assert_eq "bad owner returns error" "1" "$_rc" -assert_eq "bad owner doesn't change value" "3" "$KEEP_GENERATIONS" -assert_contains "bad owner error message" "not owned by root" "$_out" - -# Restore stat mock to uid 0 -make_mock stat " -if [[ \"\${1:-}\" == \"-c\" && \"\${2:-}\" == \"%u\" ]]; then - echo \"0\" -else - exec \"${REAL_STAT}\" \"\$@\" -fi -" - - -# ── UPGRADE_GUARD config ───────────────────────────────────── - -section "UPGRADE_GUARD config" - -# Test: UPGRADE_GUARD=0 disables guard -CONFIG_FILE="${TESTDIR}/guard_off.conf" -cat > "$CONFIG_FILE" <<'EOF' -UPGRADE_GUARD=0 -EOF -UPGRADE_GUARD=1 -load_config -assert_eq "UPGRADE_GUARD=0 loaded" "0" "$UPGRADE_GUARD" - -# Test: UPGRADE_GUARD=1 explicitly enables guard -CONFIG_FILE="${TESTDIR}/guard_on.conf" -cat > "$CONFIG_FILE" <<'EOF' -UPGRADE_GUARD=1 -EOF -UPGRADE_GUARD=0 -load_config -assert_eq "UPGRADE_GUARD=1 loaded" "1" "$UPGRADE_GUARD" - -# Test: quoted UPGRADE_GUARD values -CONFIG_FILE="${TESTDIR}/guard_quoted.conf" -cat > "$CONFIG_FILE" <<'EOF' -UPGRADE_GUARD="0" -EOF -UPGRADE_GUARD=1 -load_config -assert_eq "UPGRADE_GUARD quoted value" "0" "$UPGRADE_GUARD" - -# Test: UPGRADE_GUARD absent → default preserved -CONFIG_FILE="${TESTDIR}/guard_absent.conf" -cat > "$CONFIG_FILE" <<'EOF' -KEEP_GENERATIONS=3 -EOF -UPGRADE_GUARD=1 -load_config -assert_eq "UPGRADE_GUARD absent → stays 1" "1" "$UPGRADE_GUARD" - -# Test: UPGRADE_GUARD=0 with inline comment -CONFIG_FILE="${TESTDIR}/guard_comment.conf" -cat > "$CONFIG_FILE" <<'EOF' -UPGRADE_GUARD=0 # disable protection -EOF -UPGRADE_GUARD=1 -load_config -assert_eq "UPGRADE_GUARD with inline comment" "0" "$UPGRADE_GUARD" - -# Restore -UPGRADE_GUARD=1 - - -# ── Config whitelist security ──────────────────────────────── - -section "Config security" - -# Attempt to set dangerous variables via config — -# they must be rejected by the whitelist -CONFIG_FILE="${TESTDIR}/evil.conf" -cat > "$CONFIG_FILE" <<'EOF' -PATH=/evil/bin -LD_PRELOAD=/evil.so -HOME=/evil -KEEP_GENERATIONS=42 -EOF -KEEP_GENERATIONS=3 -_save_path="$PATH" -_save_home="$HOME" -_save_ld="${LD_PRELOAD:-}" - -# Direct call — need side effects (KEEP_GENERATIONS=42 must apply) -load_config 2>/dev/null || true - -assert_eq "evil PATH ignored" "$_save_path" "$PATH" -assert_eq "evil HOME ignored" "$_save_home" "$HOME" -assert_eq "evil LD_PRELOAD ignored" "$_save_ld" "${LD_PRELOAD:-}" -assert_eq "whitelisted key still works" "42" "$KEEP_GENERATIONS" -KEEP_GENERATIONS=3 - -# UPGRADE_GUARD must not be injectable via non-whitelisted names -CONFIG_FILE="${TESTDIR}/evil_guard.conf" -cat > "$CONFIG_FILE" <<'EOF' -UPGRADE_GUARD_OVERRIDE=0 -upgrade_guard=0 -EOF -UPGRADE_GUARD=1 -load_config 2>/dev/null || true -assert_eq "UPGRADE_GUARD not affected by similar names" "1" "$UPGRADE_GUARD" - - -# ── KERNEL_PARAMS from config ─────────────────────────────── - -section "KERNEL_PARAMS config" - -CONFIG_FILE="${TESTDIR}/params.conf" -cat > "$CONFIG_FILE" <<'EOF' -KERNEL_PARAMS=rw quiet splash -EOF -KERNEL_PARAMS="rw default" -load_config -assert_eq "KERNEL_PARAMS overridden" "rw quiet splash" "$KERNEL_PARAMS" -# Restore default -KERNEL_PARAMS="rw slab_nomerge init_on_alloc=1 page_alloc.shuffle=1 pti=on vsyscall=none randomize_kstack_offset=on debugfs=off" - - -# ── HOME_COPY_FILES config ───────────────────── - -section "HOME_COPY_FILES config" - -# Test: HOME_COPY_FILES loaded from config -CONFIG_FILE="${TESTDIR}/home_copy.conf" -cat > "$CONFIG_FILE" <<'EOF' -HOME_COPY_FILES=.bashrc .ssh .gitconfig -EOF -HOME_COPY_FILES="" -load_config -assert_eq "HOME_COPY_FILES loaded" ".bashrc .ssh .gitconfig" "$HOME_COPY_FILES" - -# Test: HOME_COPY_FILES with quotes -CONFIG_FILE="${TESTDIR}/home_copy_quoted.conf" -cat > "$CONFIG_FILE" <<'EOF' -HOME_COPY_FILES=".bashrc .bash_profile .ssh" -EOF -HOME_COPY_FILES="" -load_config -assert_eq "HOME_COPY_FILES quoted" ".bashrc .bash_profile .ssh" "$HOME_COPY_FILES" - -# Test: HOME_COPY_FILES absent → default preserved -CONFIG_FILE="${TESTDIR}/home_copy_absent.conf" -cat > "$CONFIG_FILE" <<'EOF' -KEEP_GENERATIONS=3 -EOF -HOME_COPY_FILES="" -load_config -assert_eq "HOME_COPY_FILES absent → stays empty" "" "$HOME_COPY_FILES" - -# Restore -HOME_COPY_FILES="" - -# ── CHROOT_COMMAND config ─────────────────────────────────── - -section "CHROOT_COMMAND config" - -# Test: CHROOT_COMMAND loaded from config -CONFIG_FILE="${TESTDIR}/chroot_command.conf" -cat > "$CONFIG_FILE" <<'EOF' -CHROOT_COMMAND=/usr/bin/pacman -Syu -EOF -CHROOT_COMMAND="" -load_config -assert_eq "CHROOT_COMMAND loaded from config" "/usr/bin/pacman -Syu" "$CHROOT_COMMAND" -CHROOT_COMMAND="" - -# Test: CHROOT_COMMAND with quotes -CONFIG_FILE="${TESTDIR}/chroot_command_quoted.conf" -cat > "$CONFIG_FILE" <<'EOF' -CHROOT_COMMAND="/usr/bin/pacman -S nvidia" -EOF -CHROOT_COMMAND="" -load_config -assert_eq "CHROOT_COMMAND with double quotes" "/usr/bin/pacman -S nvidia" "$CHROOT_COMMAND" -CHROOT_COMMAND="" - -# Test: CHROOT_COMMAND absent → stays empty -CONFIG_FILE="${TESTDIR}/chroot_command_absent.conf" -cat > "$CONFIG_FILE" <<'EOF' -KEEP_GENERATIONS=3 -EOF -CHROOT_COMMAND="" -load_config -assert_eq "CHROOT_COMMAND absent → stays empty" "" "$CHROOT_COMMAND" - -# -# ── config key whitespace trimming ───────────── - -section "Config key whitespace trimming" - -CONFIG_FILE="${TESTDIR}/whitespace_key.conf" -printf ' KEEP_GENERATIONS=7\n' > "$CONFIG_FILE" -printf '\tESP=/boot/efi\n' >> "$CONFIG_FILE" -KEEP_GENERATIONS=3 -ESP="/efi" -load_config -assert_eq "leading spaces in key trimmed" "7" "$KEEP_GENERATIONS" -assert_eq "leading tab in key trimmed" "/boot/efi" "$ESP" -KEEP_GENERATIONS=3 -ESP="/efi" - - -summary diff --git a/tests/test_harness.sh b/tests/test_harness.sh index 588d31e..4a88098 100644 --- a/tests/test_harness.sh +++ b/tests/test_harness.sh @@ -228,7 +228,16 @@ fi make_mock findmnt 'echo ""' make_mock mountpoint 'exit 0' -make_mock python3 'echo ""' +REAL_PYTHON3=$(command -v python3) +cat > "${MOCK_BIN}/python3" <<'MOCK' +#!/bin/bash +for arg in "$@"; do + [[ "$arg" == *config.py* ]] && exec REAL_PYTHON3_PLACEHOLDER "$@" +done +exit 0 +MOCK +sed -i "s|REAL_PYTHON3_PLACEHOLDER|${REAL_PYTHON3}|g" "${MOCK_BIN}/python3" +chmod +x "${MOCK_BIN}/python3" make_mock mount 'exit 0' make_mock btrfs 'exit 0' make_mock flock 'exit 0' diff --git a/tests/test_integration.sh b/tests/test_integration.sh index 2ec6318..6143352 100644 --- a/tests/test_integration.sh +++ b/tests/test_integration.sh @@ -114,11 +114,21 @@ fi MOCK chmod +x "${MOCK_BIN}/stat" -for cmd in mountpoint mount btrfs df python3; do +for cmd in mountpoint mount btrfs df; do printf '#!/bin/bash\nexit 0\n' > "${MOCK_BIN}/${cmd}" chmod +x "${MOCK_BIN}/${cmd}" done +REAL_PYTHON3=$(command -v python3) +cat > "${MOCK_BIN}/python3" < "${MOCK_BIN}/findmnt" <<'MOCK' #!/bin/bash echo "rw,subvol=/root-20250601-120000"