* [RESEND][RFC][PATCH 2/2] tests: add vcontainer --config / VDKR_CONFIG auth plumbing tests
2026-04-29 19:57 [RESEND][RFC][PATCH 1/2] vcontainer: add --config / VDKR_CONFIG for docker/podman auth credentials tim.orling
@ 2026-04-29 19:57 ` tim.orling
2026-04-29 20:16 ` [meta-virtualization] [RESEND][RFC][PATCH 1/2] vcontainer: add --config / VDKR_CONFIG for docker/podman auth credentials Bruce Ashfield
1 sibling, 0 replies; 3+ messages in thread
From: tim.orling @ 2026-04-29 19:57 UTC (permalink / raw)
To: meta-virtualization; +Cc: Tim Orling
From: Tim Orling <tim.orling@konsulko.com>
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 <tim.orling@konsulko.com>
---
.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
+
+
+@pytest.fixture(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
+
+
+@pytest.fixture(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
+
+
+@pytest.fixture(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()
+
+
+@pytest.fixture(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()
+
+
+@pytest.fixture(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()
+
+
+@pytest.fixture(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()
+
+
+@pytest.fixture(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()
+
+
+@pytest.fixture(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")
+
+
+@pytest.fixture(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
^ permalink raw reply related [flat|nested] 3+ messages in thread* Re: [meta-virtualization] [RESEND][RFC][PATCH 1/2] vcontainer: add --config / VDKR_CONFIG for docker/podman auth credentials
2026-04-29 19:57 [RESEND][RFC][PATCH 1/2] vcontainer: add --config / VDKR_CONFIG for docker/podman auth credentials tim.orling
2026-04-29 19:57 ` [RESEND][RFC][PATCH 2/2] tests: add vcontainer --config / VDKR_CONFIG auth plumbing tests tim.orling
@ 2026-04-29 20:16 ` Bruce Ashfield
1 sibling, 0 replies; 3+ messages in thread
From: Bruce Ashfield @ 2026-04-29 20:16 UTC (permalink / raw)
To: tim.orling; +Cc: meta-virtualization
merged.
Bruce
In message: [meta-virtualization] [RESEND][RFC][PATCH 1/2] vcontainer: add --config / VDKR_CONFIG for docker/podman auth credentials
on 29/04/2026 Tim Orling via lists.yoctoproject.org wrote:
> From: Tim Orling <tim.orling@konsulko.com>
>
> Add a VDKR_CONFIG / VPDMN_CONFIG env var and a matching --config <path>
> CLI flag that passes an existing docker config.json / podman auth.json
> into the QEMU-hosted container runtime so pulls from private registries
> work without having to retype --registry-user / --registry-pass on every
> command.
>
> Security posture (defence in depth):
> - Host-side pre-flight validation in vrunner.sh (validate_auth_config):
> reject symlinks, non-regular files, missing / unreadable files, files
> smaller than 2 bytes (minimum "{}") or larger than 1 MiB, and any
> permissions other than 0400 / 0600 / 0200. WARN if not owned by the
> invoking user.
> - Stage the file into a dedicated per-invocation directory under
> $TEMP_DIR at mode 0400 inside a 0700 parent; auto-cleanup rides the
> existing EXIT/INT/TERM trap.
> - Expose the staged file over a *separate* read-only virtio-9p tag
> ("${TOOL_NAME}_auth") so credentials cannot leak into the general
> /mnt/share input/output directory or into storage.tar outputs.
> - Only a boolean flag ("${CMDLINE_PREFIX}_auth=1") is appended to the
> kernel cmdline - never the path, the env var name, or the contents.
> - Guest mounts /mnt/auth ro,nosuid,nodev,noexec, copies to the runtime's
> canonical path, then unmounts immediately so neither the runtime nor
> user workloads keep a reference to the host staging directory.
>
> vrunner.sh:
> - Initialise AUTH_CONFIG from $VDKR_CONFIG / $VPDMN_CONFIG
> - Parse --config <path> (overrides the env vars)
> - Add validate_auth_config() and setup_auth_share() with the rules above
> - Call setup_auth_share in both the daemon start path and the
> non-daemon / batch-import path
>
> vcontainer-init-common.sh:
> - Default RUNTIME_AUTH="0" and parse ${VCONTAINER_RUNTIME_PREFIX}_auth=*
> from the kernel cmdline
> - Define mount_auth_share() / unmount_auth_share() using the per-runtime
> "${VCONTAINER_RUNTIME_NAME}_auth" 9p tag, mounted at /mnt/auth with
> ro,nosuid,nodev,noexec
>
> vdkr-init.sh:
> - install_auth_config() copies /mnt/auth/config.json to
> /root/.docker/config.json (mode 0600; parent dir 0700)
> - Called after install_registry_ca in main flow so --config takes
> precedence over --registry-user / --registry-pass; logs a NOTE when
> both mechanisms are supplied
> - Unmounts /mnt/auth after copy
>
> vpdmn-init.sh:
> - install_auth_config() copies to /run/containers/0/auth.json (the
> rootful podman canonical path) and exports REGISTRY_AUTH_FILE so the
> creds are picked up regardless of podman's search order
> - Mode 0600 on the file, 0700 on the containing directory
> - Unmounts /mnt/auth after copy
>
> vcontainer-common.sh:
> - Honour $VDKR_CONFIG / $VPDMN_CONFIG, parse --config, and forward
> AUTH_CONFIG to vrunner.sh via --config in build_runner_args
> - Document the flag and env vars in show_usage
>
> README.md:
> - New "Passing an existing docker/podman auth file (--config)" section
> with examples for both runtimes, a table of target paths, and the
> full security model
>
> AI-Generated: Claude Cowork Opus 4.7
> Signed-off-by: Tim Orling <tim.orling@konsulko.com>
> ---
> recipes-containers/vcontainer/README.md | 54 +++++++
> .../vcontainer/files/vcontainer-common.sh | 18 +++
> .../files/vcontainer-init-common.sh | 54 +++++++
> .../vcontainer/files/vdkr-init.sh | 58 +++++++
> .../vcontainer/files/vpdmn-init.sh | 61 ++++++++
> .../vcontainer/files/vrunner.sh | 147 ++++++++++++++++++
> 6 files changed, 392 insertions(+)
>
> diff --git a/recipes-containers/vcontainer/README.md b/recipes-containers/vcontainer/README.md
> index 657dd02e..e44616f4 100644
> --- a/recipes-containers/vcontainer/README.md
> +++ b/recipes-containers/vcontainer/README.md
> @@ -317,6 +317,60 @@ use `--secure-registry --ca-cert`:
> vdkr --secure-registry --ca-cert /path/to/ca.crt pull myimage
> ```
>
> +### Passing an existing docker/podman auth file (`--config`)
> +
> +If you already have credentials set up on the host (for example, from
> +running `docker login` locally), you can pass the resulting auth file
> +straight through into the emulated environment instead of re-entering
> +credentials with `--registry-user`/`--registry-pass`:
> +
> +```bash
> +# Docker (vdkr): uses ~/.docker/config.json by default
> +vdkr --config ~/.docker/config.json pull registry.example.com/myimage
> +
> +# Podman (vpdmn): uses $XDG_RUNTIME_DIR/containers/auth.json
> +vpdmn --config $XDG_RUNTIME_DIR/containers/auth.json pull registry.example.com/myimage
> +```
> +
> +The path can also be supplied via environment:
> +
> +```bash
> +export VDKR_CONFIG=$HOME/.docker/config.json
> +vdkr pull registry.example.com/myimage
> +```
> +
> +(`VPDMN_CONFIG` is honoured identically by `vpdmn`.)
> +
> +**What the file ends up as inside the VM:**
> +
> +| Tool | Target path | Notes |
> +| ----- | --------------------------------- | ------------------------------------------------- |
> +| vdkr | `/root/.docker/config.json` | Mode 0600; containing dir 0700 |
> +| vpdmn | `/run/containers/0/auth.json` | Mode 0600; `$REGISTRY_AUTH_FILE` exported |
> +
> +**Security model.** The credential file is treated as secret material:
> +
> +- The host-side file **must** be a regular file with mode `0600` or `0400`.
> + World/group-readable files are rejected outright. Symlinks are rejected.
> + Files larger than 1 MiB are rejected.
> +- On the host it is copied into a per-invocation private directory under
> + `$TMPDIR/vdkr-$$/auth_share` (mode 0700; file mode 0400) and removed
> + automatically by the `EXIT`/`INT`/`TERM` trap when `vrunner.sh` exits.
> +- It is exposed to the guest on a **dedicated** virtio-9p share whose
> + mount tag (`vdkr_auth` / `vpdmn_auth`) is distinct from the general
> + `*_share` share used for input/output. The guest mounts it **read-only**
> + at `/mnt/auth`, copies it into the runtime's credential location, then
> + **unmounts** `/mnt/auth` so nothing in the VM retains an open reference
> + to the host staging directory.
> +- Nothing about the file appears on the kernel command line. Only a
> + boolean flag (`docker_auth=1` / `podman_auth=1`) is passed so the guest
> + init script knows to look on the auth share.
> +- When both `--config` and `--registry-user`/`--registry-pass` are
> + supplied, `--config` wins and a NOTE is logged.
> +- `--config` is NOT forwarded into container workloads (it only reaches
> + the container engine's credential store); containers themselves never
> + see `/mnt/auth`.
> +
> ## Volume Mounts
>
> Mount host directories into containers using `-v` (requires memory resident mode):
> diff --git a/recipes-containers/vcontainer/files/vcontainer-common.sh b/recipes-containers/vcontainer/files/vcontainer-common.sh
> index 126ca727..8adec77d 100755
> --- a/recipes-containers/vcontainer/files/vcontainer-common.sh
> +++ b/recipes-containers/vcontainer/files/vcontainer-common.sh
> @@ -718,6 +718,11 @@ ${BOLD}GLOBAL OPTIONS:${NC}
> --registry <url> Default registry for unqualified images (e.g., 10.0.2.2:5000/yocto)
> --no-registry Disable baked-in default registry (use images as-is)
> --insecure-registry <host:port> Mark registry as insecure (HTTP). Can repeat.
> + --config <path> Registry auth file (docker config.json / podman auth.json)
> + Defaults to \$VDKR_CONFIG / \$VPDMN_CONFIG. The file must be
> + mode 0600 or stricter; it is passed to the guest over a
> + dedicated read-only virtio-9p share and never appears on
> + the kernel cmdline.
> --verbose, -v Enable verbose output
> --help, -h Show this help
>
> @@ -857,6 +862,7 @@ build_runner_args() {
> [ -n "$CA_CERT" ] && args+=("--ca-cert" "$CA_CERT")
> [ -n "$REGISTRY_USER" ] && args+=("--registry-user" "$REGISTRY_USER")
> [ -n "$REGISTRY_PASS" ] && args+=("--registry-pass" "$REGISTRY_PASS")
> + [ -n "$AUTH_CONFIG" ] && args+=("--config" "$AUTH_CONFIG")
>
> # Xen: pass exit grace period
> [ -n "${VXN_EXIT_GRACE_PERIOD:-}" ] && args+=("--exit-grace-period" "$VXN_EXIT_GRACE_PERIOD")
> @@ -880,6 +886,11 @@ SECURE_REGISTRY="false"
> CA_CERT=""
> REGISTRY_USER=""
> REGISTRY_PASS=""
> +# Registry auth config file. Env-var default depends on which CLI wrapper is
> +# in use (vdkr → $VDKR_CONFIG, vpdmn → $VPDMN_CONFIG), then falls back to the
> +# other for convenience when sharing a single host-side file. Overridden by
> +# the --config CLI flag below.
> +AUTH_CONFIG="${VDKR_CONFIG:-${VPDMN_CONFIG:-}}"
> COMMAND=""
> COMMAND_ARGS=()
>
> @@ -977,6 +988,13 @@ while [ $# -gt 0 ]; do
> REGISTRY_PASS="$2"
> shift 2
> ;;
> + --config)
> + # Path to a docker/podman registry auth file (config.json / auth.json).
> + # Overrides $VDKR_CONFIG / $VPDMN_CONFIG. Forwarded to vrunner.sh --config,
> + # which validates the file and stages it on a dedicated read-only 9p share.
> + AUTH_CONFIG="$2"
> + shift 2
> + ;;
> -it|--interactive)
> INTERACTIVE="true"
> shift
> diff --git a/recipes-containers/vcontainer/files/vcontainer-init-common.sh b/recipes-containers/vcontainer/files/vcontainer-init-common.sh
> index ab8762b2..3bd70e75 100755
> --- a/recipes-containers/vcontainer/files/vcontainer-init-common.sh
> +++ b/recipes-containers/vcontainer/files/vcontainer-init-common.sh
> @@ -156,6 +156,7 @@ parse_cmdline() {
> RUNTIME_INTERACTIVE="0"
> RUNTIME_DAEMON="0"
> RUNTIME_9P="0" # virtio-9p available for fast I/O
> + RUNTIME_AUTH="0" # registry auth config (config.json / auth.json) available on dedicated 9p share
> RUNTIME_IDLE_TIMEOUT="1800" # Default: 30 minutes
>
> for param in $(cat /proc/cmdline); do
> @@ -187,6 +188,9 @@ parse_cmdline() {
> ${VCONTAINER_RUNTIME_PREFIX}_9p=*)
> RUNTIME_9P="${param#${VCONTAINER_RUNTIME_PREFIX}_9p=}"
> ;;
> + ${VCONTAINER_RUNTIME_PREFIX}_auth=*)
> + RUNTIME_AUTH="${param#${VCONTAINER_RUNTIME_PREFIX}_auth=}"
> + ;;
> esac
> done
>
> @@ -262,6 +266,56 @@ mount_input_disk() {
> fi
> }
>
> +# ============================================================================
> +# Registry auth share (docker config.json / podman auth.json)
> +# ============================================================================
> +# The host stages a validated credential file on a *dedicated* read-only 9p
> +# share tagged "${VCONTAINER_RUNTIME_NAME}_auth" (e.g. "vdkr_auth" or
> +# "vpdmn_auth"). That tag is separate from the general ${VCONTAINER_SHARE_NAME}
> +# used for input/output so credentials can't leak into storage.tar outputs or
> +# be overwritten by daemon_send_with_input.
> +#
> +# We mount read-only, nosuid, nodev, noexec at /mnt/auth. Callers are expected
> +# to copy the credential file into the runtime's canonical location with
> +# restrictive permissions and then call unmount_auth_share() so the guest
> +# filesystem no longer has an open reference to the host-side file.
> +
> +AUTH_SHARE_TAG=""
> +AUTH_SHARE_MOUNT="/mnt/auth"
> +
> +mount_auth_share() {
> + if [ "$RUNTIME_AUTH" != "1" ]; then
> + return 1
> + fi
> +
> + AUTH_SHARE_TAG="${VCONTAINER_RUNTIME_NAME}_auth"
> + mkdir -p "$AUTH_SHARE_MOUNT"
> +
> + # trans/version/cache match the existing 9p share mount. Add:
> + # ro - guest can't mutate the host-side staging directory
> + # nosuid - no setuid binaries can be executed from the share
> + # nodev - no device nodes honoured even if crafted
> + # noexec - no code can execute from the share (auth.json is pure data)
> + if mount -t 9p \
> + -o trans=${NINE_P_TRANSPORT},version=9p2000.L,cache=none,ro,nosuid,nodev,noexec \
> + "$AUTH_SHARE_TAG" "$AUTH_SHARE_MOUNT" 2>/dev/null; then
> + log "Mounted auth 9p share at $AUTH_SHARE_MOUNT (tag: $AUTH_SHARE_TAG, ro)"
> + return 0
> + fi
> +
> + log "WARNING: Could not mount auth 9p share ($AUTH_SHARE_TAG)"
> + RUNTIME_AUTH="0"
> + return 1
> +}
> +
> +unmount_auth_share() {
> + if mountpoint -q "$AUTH_SHARE_MOUNT" 2>/dev/null; then
> + umount "$AUTH_SHARE_MOUNT" 2>/dev/null || \
> + umount -l "$AUTH_SHARE_MOUNT" 2>/dev/null || true
> + fi
> + rmdir "$AUTH_SHARE_MOUNT" 2>/dev/null || true
> +}
> +
> # ============================================================================
> # Network Configuration
> # ============================================================================
> diff --git a/recipes-containers/vcontainer/files/vdkr-init.sh b/recipes-containers/vcontainer/files/vdkr-init.sh
> index e1e869b2..4ad50668 100755
> --- a/recipes-containers/vcontainer/files/vdkr-init.sh
> +++ b/recipes-containers/vcontainer/files/vdkr-init.sh
> @@ -26,6 +26,10 @@
> # docker_registry_ca=1 CA certificate available in /mnt/share/ca.crt
> # docker_registry_user=<user> Registry username for authentication
> # docker_registry_pass=<base64> Base64-encoded registry password
> +# docker_auth=1 A pre-built docker config.json is available
> +# on a dedicated read-only 9p share tagged
> +# "vdkr_auth" (mounted at /mnt/auth). Takes
> +# precedence over docker_registry_user/pass.
> #
> # Version: 2.5.0
>
> @@ -159,6 +163,55 @@ EOF
> fi
> }
>
> +# Install a user-supplied docker config.json from the dedicated read-only
> +# auth 9p share (mounted at /mnt/auth by mount_auth_share). This takes
> +# precedence over credentials supplied via docker_registry_user/pass.
> +#
> +# Security posture:
> +# * File is read from a read-only 9p share with a separate tag ("vdkr_auth")
> +# so it cannot leak into /mnt/share outputs.
> +# * Target is written with mode 0600 and the parent dir with mode 0700.
> +# * We unmount /mnt/auth immediately after copying so neither the dockerd
> +# runtime nor user workloads in the VM have an open reference to the
> +# host-side staging directory.
> +install_auth_config() {
> + if [ "$RUNTIME_AUTH" != "1" ]; then
> + return 0
> + fi
> +
> + if ! mount_auth_share; then
> + log "WARNING: docker_auth=1 was set but the auth 9p share did not mount"
> + return 1
> + fi
> +
> + local src="$AUTH_SHARE_MOUNT/config.json"
> + if [ ! -f "$src" ]; then
> + log "WARNING: expected $src on auth share but file is missing"
> + unmount_auth_share
> + return 1
> + fi
> +
> + mkdir -p /root/.docker
> + chmod 700 /root/.docker
> +
> + if cp "$src" /root/.docker/config.json 2>/dev/null; then
> + chmod 600 /root/.docker/config.json
> + log "Installed registry auth config at /root/.docker/config.json"
> + if [ -n "$DOCKER_REGISTRY_USER" ] || [ -n "$DOCKER_REGISTRY_PASS" ]; then
> + log "NOTE: --config takes precedence over --registry-user/--registry-pass"
> + fi
> + else
> + log "ERROR: failed to copy auth config to /root/.docker/config.json"
> + unmount_auth_share
> + return 1
> + fi
> +
> + # Release the host-side share so credentials aren't still addressable
> + # through /mnt/auth for the lifetime of the VM.
> + unmount_auth_share
> + return 0
> +}
> +
> # ============================================================================
> # Docker-Specific Functions
> # ============================================================================
> @@ -684,6 +737,11 @@ parse_secure_registry_config
> # Install CA certificate for secure registry
> install_registry_ca
>
> +# Install user-supplied docker config.json from the dedicated auth 9p share.
> +# Must run AFTER install_registry_ca so that --config takes precedence when
> +# both mechanisms are used.
> +install_auth_config
> +
> # Start containerd and dockerd (Docker-specific)
> start_containerd
> start_dockerd
> diff --git a/recipes-containers/vcontainer/files/vpdmn-init.sh b/recipes-containers/vcontainer/files/vpdmn-init.sh
> index 7f661102..2036ed39 100755
> --- a/recipes-containers/vcontainer/files/vpdmn-init.sh
> +++ b/recipes-containers/vcontainer/files/vpdmn-init.sh
> @@ -20,6 +20,12 @@
> # podman_output=<type> Output type: text, tar, storage (default: text)
> # podman_state=<type> State type: none, disk (default: none)
> # podman_network=1 Enable networking (configure eth0, DNS)
> +# podman_auth=1 A pre-built registry auth file (docker config.json
> +# schema, "auths" block) is available on a dedicated
> +# read-only 9p share tagged "vpdmn_auth" (mounted at
> +# /mnt/auth). Installed as /run/containers/0/auth.json
> +# (the rootful podman default), and exported via
> +# $REGISTRY_AUTH_FILE.
> #
> # Version: 1.1.0
> #
> @@ -97,6 +103,57 @@ verify_podman() {
> fi
> }
>
> +# Install a user-supplied registry auth file from the dedicated read-only
> +# auth 9p share (mounted at /mnt/auth by mount_auth_share). Podman accepts
> +# the same "auths" JSON schema as docker config.json, so we can copy directly.
> +#
> +# Canonical rootful path is /run/containers/0/auth.json; we also export
> +# $REGISTRY_AUTH_FILE so it works regardless of podman's search order.
> +#
> +# Security posture matches vdkr-init.sh install_auth_config:
> +# * Source is a separate read-only 9p tag ("vpdmn_auth") so it cannot leak
> +# into /mnt/share outputs.
> +# * Target has mode 0600; containing dir has mode 0700.
> +# * /mnt/auth is unmounted immediately after copy so user workloads in the
> +# VM have no open reference to the host-side staging directory.
> +install_auth_config() {
> + if [ "$RUNTIME_AUTH" != "1" ]; then
> + return 0
> + fi
> +
> + if ! mount_auth_share; then
> + log "WARNING: podman_auth=1 was set but the auth 9p share did not mount"
> + return 1
> + fi
> +
> + local src="$AUTH_SHARE_MOUNT/config.json"
> + if [ ! -f "$src" ]; then
> + log "WARNING: expected $src on auth share but file is missing"
> + unmount_auth_share
> + return 1
> + fi
> +
> + # Rootful podman's default auth path
> + local auth_dir="/run/containers/0"
> + local auth_file="$auth_dir/auth.json"
> +
> + mkdir -p "$auth_dir"
> + chmod 700 "$auth_dir"
> +
> + if cp "$src" "$auth_file" 2>/dev/null; then
> + chmod 600 "$auth_file"
> + export REGISTRY_AUTH_FILE="$auth_file"
> + log "Installed registry auth config at $auth_file"
> + else
> + log "ERROR: failed to copy auth config to $auth_file"
> + unmount_auth_share
> + return 1
> + fi
> +
> + unmount_auth_share
> + return 0
> +}
> +
> # Podman is daemonless - nothing to stop
> stop_runtime_daemons() {
> :
> @@ -190,6 +247,10 @@ configure_networking
> # Verify podman is available (no daemon to start)
> verify_podman
>
> +# Install user-supplied auth config from the dedicated auth 9p share, if any.
> +# Done before command execution so pulls/logins have credentials available.
> +install_auth_config
> +
> # Handle daemon mode or single command execution
> if [ "$RUNTIME_DAEMON" = "1" ]; then
> run_daemon_mode
> diff --git a/recipes-containers/vcontainer/files/vrunner.sh b/recipes-containers/vcontainer/files/vrunner.sh
> index 1744245a..2fd61655 100755
> --- a/recipes-containers/vcontainer/files/vrunner.sh
> +++ b/recipes-containers/vcontainer/files/vrunner.sh
> @@ -38,6 +38,13 @@ TARGET_ARCH="${VDKR_ARCH:-${VPDMN_ARCH:-aarch64}}"
> TIMEOUT="${VDKR_TIMEOUT:-${VPDMN_TIMEOUT:-300}}"
> VERBOSE="${VDKR_VERBOSE:-${VPDMN_VERBOSE:-false}}"
>
> +# Registry authentication config file (docker config.json / podman auth.json).
> +# Can be set via $VDKR_CONFIG or $VPDMN_CONFIG in the environment, and is
> +# overridden by the --config CLI flag below. The file is passed into the guest
> +# over a dedicated read-only virtio-9p share and installed into the guest
> +# container runtime's credential location by the init script.
> +AUTH_CONFIG="${VDKR_CONFIG:-${VPDMN_CONFIG:-}}"
> +
> # Runtime-specific settings (set after parsing --runtime)
> set_runtime_config() {
> case "$RUNTIME" in
> @@ -232,6 +239,12 @@ OPTIONS:
> --network, -n Enable networking (slirp user-mode, outbound only)
> --registry <url> Default registry for unqualified images (e.g., 10.0.2.2:5000/yocto)
> --insecure-registry <host:port> Mark registry as insecure (HTTP). Can repeat.
> + --config <path> Path to docker/podman auth config (config.json / auth.json).
> + Defaults to $VDKR_CONFIG or $VPDMN_CONFIG from environment.
> + The file is passed to the guest over a dedicated read-only
> + virtio-9p share and installed at /root/.docker/config.json
> + (vdkr) or /run/containers/0/auth.json (vpdmn). The host file
> + must be a regular file with mode 0600 or stricter.
> --interactive, -it Run in interactive mode (connects terminal to container)
> --timeout <secs> QEMU timeout [default: 300]
> --idle-timeout <s> Daemon idle timeout in seconds [default: 1800]
> @@ -406,6 +419,13 @@ while [ $# -gt 0 ]; do
> REGISTRY_PASS="$2"
> shift 2
> ;;
> + --config)
> + # Path to a docker/podman config file (config.json / auth.json)
> + # Overrides $VDKR_CONFIG / $VPDMN_CONFIG. The file is mounted into
> + # the guest via a dedicated read-only virtio-9p share.
> + AUTH_CONFIG="$2"
> + shift 2
> + ;;
> --interactive|-it)
> INTERACTIVE="true"
> shift
> @@ -847,6 +867,124 @@ fi
> TEMP_DIR="${TMPDIR:-/tmp}/vdkr-$$"
> mkdir -p "$TEMP_DIR"
>
> +# ============================================================================
> +# Registry auth config (docker config.json / podman auth.json)
> +# ============================================================================
> +# The AUTH_CONFIG path (from $VDKR_CONFIG, $VPDMN_CONFIG, or --config) points
> +# to a file containing container-registry credentials. For defence-in-depth we:
> +# * reject non-regular files (symlinks, devices, directories)
> +# * reject files readable by group/other (mode must be <= 0600)
> +# * warn if the file is not owned by the invoking user
> +# * copy it into a private per-invocation directory under $TEMP_DIR at 0400
> +# * expose it to the guest via a *separate* read-only virtio-9p tag
> +# ("${TOOL_NAME}_auth") mounted at /mnt/auth (not the generic /mnt/share
> +# which holds input/output and is wiped between daemon commands)
> +# * never pass the file contents or path on the kernel cmdline; only a flag
> +# "${CMDLINE_PREFIX}_auth=1" to tell the init script to look at /mnt/auth
> +# * rely on the existing $TEMP_DIR EXIT/INT/TERM trap to delete the copy
> +#
> +# The auth file is never logged (path is visible, but contents are not).
> +AUTH_SHARE_DIR=""
> +
> +validate_auth_config() {
> + local path="$1"
> +
> + # Resolve symlinks to the canonical path so the perm check applies to the
> + # actual file, but still require the *named* path to be a regular file
> + # (not a symlink pointing into sensitive areas like /proc/self/environ).
> + if [ -L "$path" ]; then
> + log "ERROR" "--config must not be a symlink: $path"
> + return 1
> + fi
> + if [ ! -e "$path" ]; then
> + log "ERROR" "--config file not found: $path"
> + return 1
> + fi
> + if [ ! -f "$path" ]; then
> + log "ERROR" "--config must be a regular file: $path"
> + return 1
> + fi
> + if [ ! -r "$path" ]; then
> + log "ERROR" "--config file is not readable: $path"
> + return 1
> + fi
> +
> + # Size sanity: docker config.json / podman auth.json should be small.
> + # 1 MiB is already generous. Reject unusually large files to avoid
> + # accidentally shipping a large credential blob.
> + local size
> + size=$(stat -c %s "$path" 2>/dev/null || echo 0)
> + if [ "$size" -gt 1048576 ]; then
> + log "ERROR" "--config file is too large ($size bytes, max 1 MiB): $path"
> + return 1
> + fi
> + # Minimum valid JSON object "{}" is 2 bytes. Anything smaller (including a
> + # 0-byte truncation or a lone newline from "echo '' > file") can't be a
> + # real auth config; reject rather than silently shipping garbage.
> + if [ "$size" -lt 2 ]; then
> + log "ERROR" "--config file is empty or too small to be valid JSON: $path"
> + return 1
> + fi
> +
> + # Permission check: must not be readable by group or world.
> + local mode
> + mode=$(stat -c %a "$path" 2>/dev/null || echo 0)
> + # stat %a emits octal without leading zero. Forbid any group/other bits.
> + case "$mode" in
> + 400|600|200) ;;
> + *)
> + log "ERROR" "--config file has unsafe permissions ($mode); expected 0600 or 0400."
> + log "ERROR" "Fix with: chmod 600 \"$path\""
> + return 1
> + ;;
> + esac
> +
> + # Ownership check: warn if file is not owned by the current user.
> + local uid owner
> + uid=$(id -u)
> + owner=$(stat -c %u "$path" 2>/dev/null || echo "")
> + if [ -n "$owner" ] && [ "$owner" != "$uid" ]; then
> + log "WARN" "--config file is not owned by current user (uid=$uid, owner=$owner)"
> + fi
> +
> + return 0
> +}
> +
> +# Stage the auth config into a dedicated read-only 9p share. Must be called
> +# AFTER $TEMP_DIR exists and AFTER hypervisor backend functions are sourced.
> +# Sets $AUTH_SHARE_DIR and appends to $HV_OPTS / $KERNEL_APPEND.
> +setup_auth_share() {
> + [ -z "$AUTH_CONFIG" ] && return 0
> +
> + if ! validate_auth_config "$AUTH_CONFIG"; then
> + log "ERROR" "Refusing to stage $AUTH_CONFIG — see above."
> + exit 1
> + fi
> +
> + AUTH_SHARE_DIR="$TEMP_DIR/auth_share"
> + # 0700 so nothing outside our process can peek at the staged file.
> + mkdir -p "$AUTH_SHARE_DIR"
> + chmod 700 "$AUTH_SHARE_DIR"
> +
> + # Always stage as config.json regardless of source filename — the guest
> + # init script knows to look for this fixed name.
> + if ! cp "$AUTH_CONFIG" "$AUTH_SHARE_DIR/config.json"; then
> + log "ERROR" "Failed to stage auth config"
> + exit 1
> + fi
> + chmod 400 "$AUTH_SHARE_DIR/config.json"
> +
> + local auth_tag="${TOOL_NAME}_auth"
> + hv_build_9p_opts "$AUTH_SHARE_DIR" "$auth_tag" "readonly=on"
> + KERNEL_APPEND="$KERNEL_APPEND ${CMDLINE_PREFIX}_auth=1"
> +
> + # Deliberately log the *fact* of staging, not the path contents or
> + # credentials. The path itself is useful for debugging and appears in
> + # --verbose mode only.
> + log "INFO" "Registry auth config staged on read-only 9p share (tag=$auth_tag)"
> + log "DEBUG" "Auth source: $AUTH_CONFIG"
> +}
> +
> cleanup() {
> if [ "$KEEP_TEMP" = "true" ]; then
> log "DEBUG" "Keeping temp directory: $TEMP_DIR"
> @@ -1310,6 +1448,10 @@ if [ "$DAEMON_MODE" = "start" ]; then
> log "DEBUG" "CA certificate copied to shared folder"
> fi
>
> + # Stage registry auth config (config.json / auth.json) on a dedicated
> + # read-only 9p share. See setup_auth_share() for the security model.
> + setup_auth_share
> +
> log "INFO" "Starting daemon..."
> log "DEBUG" "PID file: $DAEMON_PID_FILE"
> log "DEBUG" "Socket: $DAEMON_SOCKET"
> @@ -1439,6 +1581,11 @@ if [ -n "$CA_CERT" ] && [ -f "$CA_CERT" ]; then
> log "DEBUG" "CA certificate available via 9p"
> fi
>
> +# Stage registry auth config (config.json / auth.json) on a dedicated read-only
> +# 9p share for non-daemon and batch-import modes. Safe to call when AUTH_CONFIG
> +# is empty — it no-ops. See setup_auth_share() for the security model.
> +setup_auth_share
> +
> log "INFO" "Starting VM ($VCONTAINER_HYPERVISOR)..."
>
> # Interactive mode runs VM in foreground with stdio connected
> --
> 2.47.3
>
>
> -=-=-=-=-=-=-=-=-=-=-=-
> Links: You receive all messages sent to this group.
> View/Reply Online (#9779): https://lists.yoctoproject.org/g/meta-virtualization/message/9779
> Mute This Topic: https://lists.yoctoproject.org/mt/119070959/1050810
> Group Owner: meta-virtualization+owner@lists.yoctoproject.org
> Unsubscribe: https://lists.yoctoproject.org/g/meta-virtualization/unsub [bruce.ashfield@gmail.com]
> -=-=-=-=-=-=-=-=-=-=-=-
>
In message: [meta-virtualization] [RESEND][RFC][PATCH 2/2] tests: add vcontainer --config / VDKR_CONFIG auth plumbing tests
on 29/04/2026 Tim Orling via lists.yoctoproject.org wrote:
> From: Tim Orling <tim.orling@konsulko.com>
>
> 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 <tim.orling@konsulko.com>
> ---
> .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
> +
> +
> +@pytest.fixture(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
> +
> +
> +@pytest.fixture(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
> +
> +
> +@pytest.fixture(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()
> +
> +
> +@pytest.fixture(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()
> +
> +
> +@pytest.fixture(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()
> +
> +
> +@pytest.fixture(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()
> +
> +
> +@pytest.fixture(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()
> +
> +
> +@pytest.fixture(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")
> +
> +
> +@pytest.fixture(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
>
>
> -=-=-=-=-=-=-=-=-=-=-=-
> Links: You receive all messages sent to this group.
> View/Reply Online (#9780): https://lists.yoctoproject.org/g/meta-virtualization/message/9780
> Mute This Topic: https://lists.yoctoproject.org/mt/119070960/1050810
> Group Owner: meta-virtualization+owner@lists.yoctoproject.org
> Unsubscribe: https://lists.yoctoproject.org/g/meta-virtualization/unsub [bruce.ashfield@gmail.com]
> -=-=-=-=-=-=-=-=-=-=-=-
>
^ permalink raw reply [flat|nested] 3+ messages in thread