[RESEND][RFC][PATCH 2/2] tests: add vcontainer --config / VDKR_CONFIG auth plumbing tests
[email protected] Wed, 29 Apr 2026 12:57:54 -0700
| Newsgroups | org.yoctoproject.lists.meta-virtualization |
|---|---|
| Message-ID | <[email protected]> |
From: Tim Orling <[email protected]> Add a new pytest module (tests/test_vcontainer_auth_config.py) covering the registry-auth-config feature introduced in the previous commit. Split into two tiers: TestAuthConfigStaticPlumbing (40 static/shell-level assertions): - vrunner.sh: AUTH_CONFIG picks up VDKR_CONFIG/VPDMN_CONFIG; --config parsing; validate_auth_config and setup_auth_share definitions; every validator reject rule (symlink / non-regular / unreadable / missing / <2B / >1MiB / mode whitelist 400|600|200 / non-owner WARN); 0700 staging dir and 0400 staged file; readonly=on on the 9p share; dedicated ${TOOL_NAME}_auth tag. Critically also asserts that AUTH_CONFIG, VDKR_CONFIG and VPDMN_CONFIG never appear in KERNEL_APPEND - only the ${CMDLINE_PREFIX}_auth=1 flag does. - vcontainer-common.sh: env-var init, --config parsing, AUTH_CONFIG forwarding via --config to vrunner, and show_usage documentation. - vcontainer-init-common.sh: RUNTIME_AUTH default, cmdline parsing, mount_auth_share/unmount_auth_share presence, dedicated per-runtime ${VCONTAINER_RUNTIME_NAME}_auth tag, and the ro,nosuid,nodev,noexec mount options. - vdkr-init.sh: install_auth_config present, writes to /root/.docker/config.json with 0600 and 0700 parent, mount + unmount pairing, precedence NOTE logged, and ordering after install_registry_ca so --config wins over --registry-user/-pass. - vpdmn-init.sh: writes to /run/containers/0/auth.json with matching modes, exports REGISTRY_AUTH_FILE, mount/unmount pairing, and ordering after verify_podman. - README.md: --config section exists and documents both env vars and both runtime target paths. TestAuthConfigValidator (13 functional cases): - Extracts validate_auth_config() from vrunner.sh with a brace-matching parser, sources it in a bash subshell with a stubbed log() helper, and drives it with real files: accepts modes 0600 / 0400, accepts the 2-byte minimum "{}", rejects missing / symlink / directory / empty / 1-byte / >1 MiB / 0644 (world-readable) / 0640 / 0700 (owner-exec) / 0000 (unreadable, skipped when running as root). Path resolution is resilient: VCONTAINER_FILES_DIR env override first, otherwise repo-relative to the test file, falling back to the /opt/bruce/poky path used elsewhere in the suite. No tests need QEMU, a registry, or network. All 53 tests complete in ~0.1s. Add tests/__pycache__ to .gitignore. AI-Generated: Claude Cowork Opus 4.7 Signed-off-by: Tim Orling <[email protected]> --- .gitignore | 1 + tests/test_vcontainer_auth_config.py | 642 +++++++++++++++++++++++++++ 2 files changed, 643 insertions(+) create mode 100644 tests/test_vcontainer_auth_config.py diff --git a/.gitignore b/.gitignore index daeb43d5..49b373f8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ pyshtables.py *~ scripts/lib/wic/plugins/source/__pycache__ lib/oeqa/runtime/cases/__pycache__ +tests/__pycache__ diff --git a/tests/test_vcontainer_auth_config.py b/tests/test_vcontainer_auth_config.py new file mode 100644 index 00000000..2e7093aa --- /dev/null +++ b/tests/test_vcontainer_auth_config.py @@ -0,0 +1,642 @@ +# SPDX-FileCopyrightText: Copyright (C) 2026 Konsulko Group +# +# SPDX-License-Identifier: MIT +""" +Tests for the vcontainer registry-auth-config plumbing ("--config" / +$VDKR_CONFIG / $VPDMN_CONFIG). + +These tests are split into two tiers: + +Tier 1 - static/shell-level (TestAuthConfigStaticPlumbing): + Reads the shell scripts under recipes-containers/vcontainer/files/ and the + README and asserts that the expected function definitions, call sites, + kernel cmdline flags, permission modes, mount options, and documentation + blocks are present. These tests need no infrastructure and run in <1s. + +Tier 2 - functional validator (TestAuthConfigValidator): + Extracts validate_auth_config() from vrunner.sh, sources it in a bash + subshell with a stubbed log() function, and drives it with a table of + inputs covering the perm / size / symlink / ownership / regular-file + rules. Also runs in <1s per case. + +Tier 3 (live registry pull with --config) is intentionally NOT in this file. +It belongs alongside test_vdkr_registry.py once the registry fixture grows a +credentials-required mode. + +Run with: + pytest tests/test_vcontainer_auth_config.py -v +""" + +import os +import re +import stat +import subprocess +import textwrap +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Locate the vcontainer files/ directory. +# --------------------------------------------------------------------------- +# +# Resolution order: +# 1. VCONTAINER_FILES_DIR environment variable (explicit override) +# 2. <repo-root>/recipes-containers/vcontainer/files/ relative to this test +# (i.e. tests/../recipes-containers/vcontainer/files/) +# 3. /opt/bruce/poky/meta-virtualization/recipes-containers/vcontainer/files/ +# (matches the pattern used by test_container_registry_script.py) +# +# If none of these are present, every test in this module is skipped. +_TESTS_DIR = Path(__file__).resolve().parent +_DEFAULT_CANDIDATES = [ + _TESTS_DIR.parent / "recipes-containers" / "vcontainer" / "files", + Path("/opt/bruce/poky/meta-virtualization/recipes-containers/vcontainer/files"), +] + + +def _find_files_dir() -> Path: + override = os.environ.get("VCONTAINER_FILES_DIR") + if override: + return Path(override) + for c in _DEFAULT_CANDIDATES: + if c.is_dir(): + return c + return _DEFAULT_CANDIDATES[0] # return first, skip in fixture if missing + + [email protected](scope="module") +def files_dir() -> Path: + d = _find_files_dir() + if not d.is_dir(): + pytest.skip(f"vcontainer files/ dir not found: {d}") + return d + + [email protected](scope="module") +def repo_root() -> Path: + # The vcontainer files live at <root>/recipes-containers/vcontainer/files, + # so the repo root is two levels up. + d = _find_files_dir() + return d.parent.parent.parent + + [email protected](scope="module") +def vrunner_sh(files_dir: Path) -> str: + p = files_dir / "vrunner.sh" + if not p.is_file(): + pytest.skip(f"vrunner.sh not found: {p}") + return p.read_text() + + [email protected](scope="module") +def vcontainer_common_sh(files_dir: Path) -> str: + p = files_dir / "vcontainer-common.sh" + if not p.is_file(): + pytest.skip(f"vcontainer-common.sh not found: {p}") + return p.read_text() + + [email protected](scope="module") +def init_common_sh(files_dir: Path) -> str: + p = files_dir / "vcontainer-init-common.sh" + if not p.is_file(): + pytest.skip(f"vcontainer-init-common.sh not found: {p}") + return p.read_text() + + [email protected](scope="module") +def vdkr_init_sh(files_dir: Path) -> str: + p = files_dir / "vdkr-init.sh" + if not p.is_file(): + pytest.skip(f"vdkr-init.sh not found: {p}") + return p.read_text() + + [email protected](scope="module") +def vpdmn_init_sh(files_dir: Path) -> str: + p = files_dir / "vpdmn-init.sh" + if not p.is_file(): + pytest.skip(f"vpdmn-init.sh not found: {p}") + return p.read_text() + + [email protected](scope="module") +def readme_md(repo_root: Path) -> str: + p = repo_root / "recipes-containers" / "vcontainer" / "README.md" + if not p.is_file(): + pytest.skip(f"vcontainer README.md not found: {p}") + return p.read_text() + + +# --------------------------------------------------------------------------- +# Tier 1: Static / shell-level plumbing assertions +# --------------------------------------------------------------------------- + + +class TestAuthConfigStaticPlumbing: + """Shell-script-level assertions for the --config / VDKR_CONFIG feature.""" + + # --- vrunner.sh -------------------------------------------------------- + + def test_vrunner_defines_auth_config_from_env(self, vrunner_sh): + """AUTH_CONFIG picks up $VDKR_CONFIG or $VPDMN_CONFIG by default.""" + assert re.search( + r'AUTH_CONFIG="\$\{VDKR_CONFIG:-\$\{VPDMN_CONFIG:-\}\}"', vrunner_sh + ), "vrunner.sh should initialise AUTH_CONFIG from VDKR_CONFIG/VPDMN_CONFIG" + + def test_vrunner_accepts_config_flag(self, vrunner_sh): + """`--config <path>` is parsed and assigned to AUTH_CONFIG.""" + # The case label ("--config") plus the assignment should both exist. + # Allow interleaved comment lines between the label and the assignment. + assert re.search( + r'--config\)\s*\n(?:\s*#[^\n]*\n)*\s*AUTH_CONFIG="\$2"', + vrunner_sh, + ), "vrunner.sh should parse --config and set AUTH_CONFIG=\"$2\"" + + def test_vrunner_defines_validate_auth_config(self, vrunner_sh): + assert "validate_auth_config()" in vrunner_sh, \ + "vrunner.sh should define validate_auth_config()" + + def test_vrunner_defines_setup_auth_share(self, vrunner_sh): + assert "setup_auth_share()" in vrunner_sh, \ + "vrunner.sh should define setup_auth_share()" + + def test_vrunner_validator_rejects_symlinks(self, vrunner_sh): + """Symlinks are rejected outright to block /proc/self/environ tricks.""" + assert re.search(r'if \[ -L "\$path" \]', vrunner_sh), \ + "validate_auth_config must reject symlinks with `[ -L $path ]`" + + def test_vrunner_validator_requires_regular_file(self, vrunner_sh): + assert re.search(r'if \[ ! -f "\$path" \]', vrunner_sh), \ + "validate_auth_config must require a regular file (-f)" + + def test_vrunner_validator_requires_readable(self, vrunner_sh): + assert re.search(r'if \[ ! -r "\$path" \]', vrunner_sh), \ + "validate_auth_config must require the file be readable (-r)" + + def test_vrunner_validator_checks_missing(self, vrunner_sh): + assert re.search(r'if \[ ! -e "\$path" \]', vrunner_sh), \ + "validate_auth_config must detect missing files (-e)" + + def test_vrunner_validator_min_size(self, vrunner_sh): + """Files smaller than 2 bytes (minimum "{}" JSON) are rejected.""" + assert re.search(r'size"?\s*-lt\s*2', vrunner_sh), \ + "validate_auth_config must reject files smaller than 2 bytes" + + def test_vrunner_validator_max_size(self, vrunner_sh): + """Files larger than 1 MiB are rejected.""" + assert "1048576" in vrunner_sh, \ + "validate_auth_config must reject files larger than 1 MiB (1048576)" + + def test_vrunner_validator_mode_whitelist(self, vrunner_sh): + """Permission modes are restricted to 400 / 600 / 200.""" + # We accept either a case statement or equivalent chain; the canonical + # form in the source is a case statement that matches these literals. + assert re.search(r'400\s*\|\s*600\s*\|\s*200', vrunner_sh), ( + "validate_auth_config must whitelist modes 400|600|200 only" + ) + + def test_vrunner_validator_warns_on_wrong_owner(self, vrunner_sh): + """Non-owner files trigger a WARN but don't reject (documented).""" + assert re.search(r'WARN.*not owned by current user', vrunner_sh), \ + "validate_auth_config must WARN when file is not owned by current user" + + def test_vrunner_setup_auth_share_permissions(self, vrunner_sh): + """Staging dir is 700 and staged file is 400.""" + assert "chmod 700" in vrunner_sh, \ + "setup_auth_share must chmod 700 the staging directory" + assert re.search(r'chmod 400[^\n]*config\.json', vrunner_sh), \ + "setup_auth_share must chmod 400 the staged config.json" + + def test_vrunner_setup_auth_share_readonly_9p(self, vrunner_sh): + """The 9p share is created with readonly=on.""" + assert 'hv_build_9p_opts' in vrunner_sh and 'readonly=on' in vrunner_sh, ( + "setup_auth_share must pass readonly=on to hv_build_9p_opts" + ) + + def test_vrunner_setup_auth_share_uses_dedicated_tag(self, vrunner_sh): + """Auth 9p tag is TOOL_NAME_auth (separate from the shared /mnt/share).""" + assert re.search(r'auth_tag="\$\{TOOL_NAME\}_auth"', vrunner_sh), ( + 'setup_auth_share must use a dedicated "${TOOL_NAME}_auth" 9p tag' + ) + + def test_vrunner_auth_cmdline_is_flag_only(self, vrunner_sh): + """Only a boolean flag (_auth=1) is appended - never the path or contents.""" + # Flag is appended: + assert re.search( + r'KERNEL_APPEND="\$KERNEL_APPEND \$\{CMDLINE_PREFIX\}_auth=1"', + vrunner_sh, + ), "vrunner.sh must append `${CMDLINE_PREFIX}_auth=1` to KERNEL_APPEND" + + # And the path / env var names must NEVER land in KERNEL_APPEND. + # Scan every line that mutates KERNEL_APPEND and prove none mention + # AUTH_CONFIG, VDKR_CONFIG, or VPDMN_CONFIG. + for ln in vrunner_sh.splitlines(): + if "KERNEL_APPEND=" in ln or "KERNEL_APPEND+=" in ln: + assert "AUTH_CONFIG" not in ln, ( + f"KERNEL_APPEND must not carry AUTH_CONFIG: {ln!r}" + ) + assert "VDKR_CONFIG" not in ln, ( + f"KERNEL_APPEND must not carry VDKR_CONFIG: {ln!r}" + ) + assert "VPDMN_CONFIG" not in ln, ( + f"KERNEL_APPEND must not carry VPDMN_CONFIG: {ln!r}" + ) + + def test_vrunner_setup_auth_share_called_in_both_paths(self, vrunner_sh): + """setup_auth_share is called at least twice (daemon + non-daemon paths).""" + # Count *call sites*, not the definition. The definition line has a '(' right after. + call_sites = [ + ln for ln in vrunner_sh.splitlines() + if re.search(r'\bsetup_auth_share\b', ln) + and "()" not in ln + and not ln.lstrip().startswith("#") + ] + assert len(call_sites) >= 2, ( + f"setup_auth_share should be invoked in both daemon and non-daemon " + f"paths; found {len(call_sites)} call site(s): {call_sites}" + ) + + # --- vcontainer-common.sh --------------------------------------------- + + def test_common_inits_auth_config_from_env(self, vcontainer_common_sh): + assert re.search( + r'AUTH_CONFIG="\$\{VDKR_CONFIG:-\$\{VPDMN_CONFIG:-\}\}"', + vcontainer_common_sh, + ), "vcontainer-common.sh should init AUTH_CONFIG from VDKR_CONFIG/VPDMN_CONFIG" + + def test_common_parses_config_flag(self, vcontainer_common_sh): + assert re.search( + r'--config\)\s*\n(?:\s*#[^\n]*\n)*\s*(?:#[^\n]*\n\s*)*AUTH_CONFIG="\$2"', + vcontainer_common_sh, + ), "vcontainer-common.sh should parse --config into AUTH_CONFIG" + + def test_common_forwards_auth_config_to_runner(self, vcontainer_common_sh): + """AUTH_CONFIG is forwarded as --config to vrunner.sh.""" + assert re.search( + r'\[ -n "\$AUTH_CONFIG" \].*args\+=\("--config" "\$AUTH_CONFIG"\)', + vcontainer_common_sh, + ), "vcontainer-common.sh must forward AUTH_CONFIG via --config to vrunner" + + def test_common_show_usage_documents_config(self, vcontainer_common_sh): + """--config appears in show_usage help output.""" + assert re.search(r'--config\s+<path>', vcontainer_common_sh), ( + "show_usage must document --config <path>" + ) + assert "VDKR_CONFIG" in vcontainer_common_sh, \ + "show_usage must mention VDKR_CONFIG env var" + assert "VPDMN_CONFIG" in vcontainer_common_sh, \ + "show_usage must mention VPDMN_CONFIG env var" + + # --- vcontainer-init-common.sh ---------------------------------------- + + def test_init_common_defaults_runtime_auth(self, init_common_sh): + assert re.search(r'RUNTIME_AUTH="0"', init_common_sh), \ + "init-common must default RUNTIME_AUTH to 0" + + def test_init_common_parses_auth_flag(self, init_common_sh): + """Kernel cmdline <prefix>_auth=* is parsed into RUNTIME_AUTH.""" + assert re.search( + r'\$\{VCONTAINER_RUNTIME_PREFIX\}_auth=\*', init_common_sh + ), "init-common must parse ${VCONTAINER_RUNTIME_PREFIX}_auth=* cmdline arg" + assert re.search( + r'RUNTIME_AUTH="\$\{param#\$\{VCONTAINER_RUNTIME_PREFIX\}_auth=\}"', + init_common_sh, + ), "init-common must strip _auth= prefix into RUNTIME_AUTH" + + def test_init_common_defines_mount_helpers(self, init_common_sh): + assert "mount_auth_share()" in init_common_sh, \ + "init-common must define mount_auth_share()" + assert "unmount_auth_share()" in init_common_sh, \ + "init-common must define unmount_auth_share()" + + def test_init_common_mount_uses_dedicated_tag(self, init_common_sh): + """mount_auth_share uses ${VCONTAINER_RUNTIME_NAME}_auth tag.""" + assert re.search( + r'AUTH_SHARE_TAG="\$\{VCONTAINER_RUNTIME_NAME\}_auth"', + init_common_sh, + ), "mount_auth_share must use a per-runtime _auth 9p tag" + + def test_init_common_mount_options_hardened(self, init_common_sh): + """Auth share is mounted ro,nosuid,nodev,noexec.""" + # All four options must be present on the mount command. + # Find the mount call to be sure we're looking at the right line. + m = re.search( + r'mount -t 9p[^\n]*\\\n[^\n]*trans=\$\{NINE_P_TRANSPORT\}[^\n]*', + init_common_sh, + ) + assert m, "mount_auth_share must issue a mount -t 9p call" + # The options are on the continuation line; grab the paragraph. + start = m.start() + end = init_common_sh.find('"$AUTH_SHARE_TAG"', start) + block = init_common_sh[start:end if end != -1 else start + 400] + for opt in ("ro", "nosuid", "nodev", "noexec"): + assert opt in block, f"mount_auth_share must include {opt} mount option" + + def test_init_common_mount_guarded_by_runtime_auth(self, init_common_sh): + """mount_auth_share returns early when RUNTIME_AUTH != 1.""" + # Find "mount_auth_share()" and assert the first ~15 lines contain the guard. + idx = init_common_sh.find("mount_auth_share()") + assert idx != -1 + snippet = init_common_sh[idx:idx + 400] + assert re.search(r'if \[ "\$RUNTIME_AUTH" != "1" \]', snippet), ( + "mount_auth_share must early-return when RUNTIME_AUTH != 1" + ) + + # --- vdkr-init.sh ------------------------------------------------------ + + def test_vdkr_defines_install_auth_config(self, vdkr_init_sh): + assert "install_auth_config()" in vdkr_init_sh, \ + "vdkr-init.sh must define install_auth_config()" + + def test_vdkr_target_path_and_modes(self, vdkr_init_sh): + """Target is /root/.docker/config.json; mode 0600; parent 0700.""" + assert "/root/.docker/config.json" in vdkr_init_sh, ( + "vdkr-init must write credentials to /root/.docker/config.json" + ) + assert "chmod 700 /root/.docker" in vdkr_init_sh, \ + "vdkr-init must chmod 700 /root/.docker" + assert "chmod 600 /root/.docker/config.json" in vdkr_init_sh, \ + "vdkr-init must chmod 600 /root/.docker/config.json" + + def test_vdkr_calls_mount_and_unmount(self, vdkr_init_sh): + assert "mount_auth_share" in vdkr_init_sh + assert "unmount_auth_share" in vdkr_init_sh, ( + "vdkr-init must unmount /mnt/auth after copying" + ) + + def test_vdkr_logs_precedence_note(self, vdkr_init_sh): + """When --config and --registry-user/--registry-pass are both set, log a NOTE.""" + assert re.search( + r'NOTE:\s*--config\s*takes precedence over\s*--registry-user/--registry-pass', + vdkr_init_sh, + ), "vdkr-init must log a precedence NOTE when both mechanisms are supplied" + + def test_vdkr_install_auth_config_after_ca(self, vdkr_init_sh): + """install_auth_config runs after install_registry_ca in main flow.""" + # Find the call sites (not the definitions). Each name should appear + # at least once at column 0 (bare call) after the function bodies. + # A simpler, resilient check: the LAST occurrence of install_registry_ca + # should appear before the LAST occurrence of install_auth_config. + last_ca = vdkr_init_sh.rfind("install_registry_ca") + last_auth = vdkr_init_sh.rfind("install_auth_config") + assert last_ca != -1 and last_auth != -1 + assert last_ca < last_auth, ( + "install_auth_config must be called AFTER install_registry_ca " + "so --config wins on precedence" + ) + + # --- vpdmn-init.sh ----------------------------------------------------- + + def test_vpdmn_defines_install_auth_config(self, vpdmn_init_sh): + assert "install_auth_config()" in vpdmn_init_sh, \ + "vpdmn-init.sh must define install_auth_config()" + + def test_vpdmn_target_path_and_modes(self, vpdmn_init_sh): + """Target is /run/containers/0/auth.json with 0600; dir 0700.""" + assert "/run/containers/0" in vpdmn_init_sh, \ + "vpdmn-init must write to /run/containers/0 (rootful podman default)" + assert re.search(r'auth_file="\$auth_dir/auth\.json"', vpdmn_init_sh), \ + "vpdmn-init must write to .../auth.json" + assert re.search(r'chmod 700 "\$auth_dir"', vpdmn_init_sh), \ + "vpdmn-init must chmod 700 the auth dir" + assert re.search(r'chmod 600 "\$auth_file"', vpdmn_init_sh), \ + "vpdmn-init must chmod 600 the auth.json" + + def test_vpdmn_exports_registry_auth_file(self, vpdmn_init_sh): + """REGISTRY_AUTH_FILE is exported so podman finds the creds.""" + assert re.search(r'export REGISTRY_AUTH_FILE="\$auth_file"', vpdmn_init_sh), ( + "vpdmn-init must export REGISTRY_AUTH_FILE" + ) + + def test_vpdmn_calls_mount_and_unmount(self, vpdmn_init_sh): + assert "mount_auth_share" in vpdmn_init_sh + assert "unmount_auth_share" in vpdmn_init_sh, ( + "vpdmn-init must unmount /mnt/auth after copying" + ) + + def test_vpdmn_install_auth_config_after_verify_podman(self, vpdmn_init_sh): + """install_auth_config runs after verify_podman in the main flow.""" + last_verify = vpdmn_init_sh.rfind("verify_podman") + last_auth = vpdmn_init_sh.rfind("install_auth_config") + assert last_verify != -1 and last_auth != -1 + assert last_verify < last_auth, ( + "install_auth_config should be called AFTER verify_podman" + ) + + # --- README.md --------------------------------------------------------- + + def test_readme_documents_config_section(self, readme_md): + assert "Passing an existing docker/podman auth file" in readme_md, ( + "README must document the --config feature" + ) + + def test_readme_lists_env_vars(self, readme_md): + assert "VDKR_CONFIG" in readme_md, "README must document VDKR_CONFIG" + assert "VPDMN_CONFIG" in readme_md, "README must document VPDMN_CONFIG" + + def test_readme_lists_target_paths(self, readme_md): + """Both runtime target paths appear in the doc.""" + assert "/root/.docker/config.json" in readme_md, \ + "README must document the vdkr target path" + assert "/run/containers/0/auth.json" in readme_md, \ + "README must document the vpdmn target path" + + +# --------------------------------------------------------------------------- +# Tier 2: Functional validator tests (bash subshell, no QEMU). +# --------------------------------------------------------------------------- + + +def _extract_validate_auth_config(vrunner_text: str) -> str: + """Extract the validate_auth_config function body from vrunner.sh. + + Parses from "validate_auth_config() {" to its matching top-level closing + brace. Simple brace-counting suffices because the function body only + contains shell constructs (no here-docs that start with '{'). + """ + start = vrunner_text.find("validate_auth_config()") + assert start != -1, "validate_auth_config not found in vrunner.sh" + # Jump to the opening brace of the function. + brace = vrunner_text.find("{", start) + assert brace != -1 + depth = 0 + i = brace + n = len(vrunner_text) + while i < n: + ch = vrunner_text[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return vrunner_text[start : i + 1] + i += 1 + raise AssertionError("Unterminated validate_auth_config definition") + + [email protected](scope="module") +def validator_harness(vrunner_sh, tmp_path_factory) -> Path: + """Create a tiny bash script that sources validate_auth_config + runs it. + + The harness is parameterised by $1 = path argument. It prints validator + output to stderr (as vrunner does) and exits with the validator's code. + """ + body = _extract_validate_auth_config(vrunner_sh) + harness = textwrap.dedent( + """\ + #!/usr/bin/env bash + # Test harness for validate_auth_config (extracted from vrunner.sh). + + # Stub the log() helper used by validate_auth_config. Route everything + # to stderr so the test can grep on captured stderr. + log() { + local level="$1" + shift + echo "[$level] $*" 1>&2 + } + + %s + + validate_auth_config "$1" + exit $? + """ + ) % body + + out = tmp_path_factory.mktemp("auth_validator") / "harness.sh" + out.write_text(harness) + out.chmod(0o700) + return out + + +def _run_validator(harness: Path, path_arg: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", str(harness), path_arg], + capture_output=True, + text=True, + timeout=10, + ) + + +class TestAuthConfigValidator: + """Functional tests for validate_auth_config() in vrunner.sh.""" + + def test_accepts_valid_mode_600(self, validator_harness, tmp_path): + f = tmp_path / "config.json" + f.write_text('{"auths":{}}') + os.chmod(f, 0o600) + r = _run_validator(validator_harness, str(f)) + assert r.returncode == 0, f"expected accept, got {r.returncode}\nstderr={r.stderr}" + + def test_accepts_valid_mode_400(self, validator_harness, tmp_path): + f = tmp_path / "config.json" + f.write_text('{"auths":{}}') + os.chmod(f, 0o400) + r = _run_validator(validator_harness, str(f)) + assert r.returncode == 0, f"expected accept, got {r.returncode}\nstderr={r.stderr}" + + def test_accepts_minimum_two_byte_json(self, validator_harness, tmp_path): + """A 2-byte file ('{}' with no trailing newline) is the minimum valid size.""" + f = tmp_path / "config.json" + f.write_bytes(b"{}") + os.chmod(f, 0o600) + r = _run_validator(validator_harness, str(f)) + assert r.returncode == 0, ( + f"expected accept for 2-byte file, got {r.returncode}\nstderr={r.stderr}" + ) + + def test_rejects_missing_file(self, validator_harness, tmp_path): + r = _run_validator(validator_harness, str(tmp_path / "no-such-file")) + assert r.returncode != 0 + assert "not found" in r.stderr + + def test_rejects_symlink(self, validator_harness, tmp_path): + target = tmp_path / "real.json" + target.write_text('{"auths":{}}') + os.chmod(target, 0o600) + link = tmp_path / "link.json" + link.symlink_to(target) + r = _run_validator(validator_harness, str(link)) + assert r.returncode != 0 + assert "symlink" in r.stderr + + def test_rejects_directory(self, validator_harness, tmp_path): + d = tmp_path / "adir" + d.mkdir() + r = _run_validator(validator_harness, str(d)) + assert r.returncode != 0 + # Directories trip the -L check first on some shells; either error is fine. + assert "regular file" in r.stderr or "not readable" in r.stderr or "symlink" not in r.stderr + + def test_rejects_empty_file(self, validator_harness, tmp_path): + f = tmp_path / "empty.json" + f.write_bytes(b"") + os.chmod(f, 0o600) + r = _run_validator(validator_harness, str(f)) + assert r.returncode != 0 + assert "empty or too small" in r.stderr + + def test_rejects_one_byte_file(self, validator_harness, tmp_path): + """A single-byte file (e.g. lone newline from 'echo > file') is rejected.""" + f = tmp_path / "tiny.json" + f.write_bytes(b"\n") + os.chmod(f, 0o600) + r = _run_validator(validator_harness, str(f)) + assert r.returncode != 0 + assert "empty or too small" in r.stderr + + def test_rejects_oversize_file(self, validator_harness, tmp_path): + """Files > 1 MiB are rejected.""" + f = tmp_path / "big.json" + # 1 MiB + 1 byte. + f.write_bytes(b"{" + b"a" * (1024 * 1024) + b"}") + os.chmod(f, 0o600) + r = _run_validator(validator_harness, str(f)) + assert r.returncode != 0 + assert "too large" in r.stderr + + def test_rejects_world_readable(self, validator_harness, tmp_path): + """Mode 0644 (group/other readable) is rejected.""" + f = tmp_path / "config.json" + f.write_text('{"auths":{}}') + os.chmod(f, 0o644) + r = _run_validator(validator_harness, str(f)) + assert r.returncode != 0 + assert "unsafe permissions" in r.stderr + + def test_rejects_group_readable(self, validator_harness, tmp_path): + """Mode 0640 (group readable) is rejected.""" + f = tmp_path / "config.json" + f.write_text('{"auths":{}}') + os.chmod(f, 0o640) + r = _run_validator(validator_harness, str(f)) + assert r.returncode != 0 + assert "unsafe permissions" in r.stderr + + def test_rejects_executable(self, validator_harness, tmp_path): + """Mode 0700 (owner-exec) is rejected - we only permit r/w combos.""" + f = tmp_path / "config.json" + f.write_text('{"auths":{}}') + os.chmod(f, 0o700) + r = _run_validator(validator_harness, str(f)) + assert r.returncode != 0 + assert "unsafe permissions" in r.stderr + + def test_rejects_unreadable(self, validator_harness, tmp_path): + """A mode 0000 file cannot be read by the invoking user.""" + if os.geteuid() == 0: + pytest.skip("running as root; DAC permission checks are bypassed") + f = tmp_path / "config.json" + f.write_text('{"auths":{}}') + # 0000: no bits at all. + os.chmod(f, 0o000) + try: + r = _run_validator(validator_harness, str(f)) + assert r.returncode != 0 + # Either "not readable" wins, or the mode-check fires; accept either. + assert "not readable" in r.stderr or "unsafe permissions" in r.stderr + finally: + # Restore perms so pytest can clean up the tmp tree. + os.chmod(f, 0o600) -- 2.47.3