All of lore.kernel.org
 help / color / mirror / Atom feed
* [RESEND][RFC][PATCH 1/2] vcontainer: add --config / VDKR_CONFIG for docker/podman auth credentials
@ 2026-04-29 19:57 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 ` [meta-virtualization] [RESEND][RFC][PATCH 1/2] vcontainer: add --config / VDKR_CONFIG for docker/podman auth credentials Bruce Ashfield
  0 siblings, 2 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 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



^ permalink raw reply related	[flat|nested] 3+ messages in thread

* [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

end of thread, other threads:[~2026-04-29 20:16 UTC | newest]

Thread overview: 3+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [meta-virtualization] [RESEND][RFC][PATCH 1/2] vcontainer: add --config / VDKR_CONFIG for docker/podman auth credentials Bruce Ashfield

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.