diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 002ac21a..2df44088 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -1011,6 +1011,37 @@ apply_runtime_user() { set_lxc_directive lxc.init.gid "$gid" } +set_runtime_environment() { + local name=$1 value=$2 temporary + [[ $name =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "$(translate "Invalid variable name:") $name" + [[ $value != *$'\n'* && $value != *$'\r'* ]] || die "$(translate "The variable contains line breaks:") $name" + if LC_ALL=C grep -q '[[:cntrl:]]' <<<"$value"; then + die "$(translate "The variable contains control characters:") $name" + fi + temporary=$(mktemp) + awk -v prefix="lxc.environment.runtime: ${name}=" 'index($0, prefix) != 1' "$CONF" >"$temporary" + printf 'lxc.environment.runtime: %s=%s\n' "$name" "$value" >>"$temporary" + cat "$temporary" >"$CONF" + rm -f "$temporary" +} + +ensure_runtime_home() { + local uid home rootfs passwd_file + grep -q '^lxc.environment.runtime: HOME=' "$CONF" && return 0 + uid=$(awk -F': ' '$1 == "lxc.init.uid" {print $2; exit}' "$CONF" 2>/dev/null || true) + uid=${uid:-0} + [[ $uid =~ ^[0-9]+$ ]] || return 0 + rootfs="/var/lib/lxc/${VMID}/rootfs" + passwd_file="${rootfs}/etc/passwd" + mount_ct_rootfs + if ! home=$(python3 "$OCI_RUNTIME_RESOLVER" --home "$passwd_file" "$uid" 2>>"${OCI_LOG:-/dev/stderr}"); then + oci_quiet pct unmount "$VMID" || true + return 0 + fi + oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")" + set_runtime_environment HOME "$home" +} + apply_runtime_groups() { local groups_csv=$1 rootfs="/var/lib/lxc/${VMID}/rootfs" local group_file="${rootfs}/etc/group" existing resolved group failed=0 @@ -1883,21 +1914,13 @@ apply_native_device_permissions # PVE represents the public env property as repeated native LXC runtime lines. # Merge only Compose overrides while the newly-created CT is stopped. while IFS=$'\t' read -r NAME ENCODED; do - [[ $NAME =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "$(translate "Invalid variable name:") $NAME" VALUE=$(printf '%s' "$ENCODED" | base64 -d) - [[ $VALUE != *$'\n'* && $VALUE != *$'\r'* ]] || die "$(translate "The variable contains line breaks:") $NAME" - if LC_ALL=C grep -q '[[:cntrl:]]' <<<"$VALUE"; then - die "$(translate "The variable contains control characters:") $NAME" - fi - TEMP_CONF=$(mktemp) - awk -v prefix="lxc.environment.runtime: ${NAME}=" 'index($0, prefix) != 1' "$CONF" >"$TEMP_CONF" - printf 'lxc.environment.runtime: %s=%s\n' "$NAME" "$VALUE" >>"$TEMP_CONF" - cat "$TEMP_CONF" >"$CONF" - rm -f "$TEMP_CONF" + set_runtime_environment "$NAME" "$VALUE" done < <(jq -r '.environment[] | [.name, (.value | @base64)] | @tsv' "$DEPLOYMENT_FILE") apply_extra_hosts apply_installer_profile +ensure_runtime_home apply_rlimits apply_host_monitor apply_kept_settings diff --git a/oci/remote/oci_runtime.py b/oci/remote/oci_runtime.py index 630795f2..ee3b9d56 100644 --- a/oci/remote/oci_runtime.py +++ b/oci/remote/oci_runtime.py @@ -89,7 +89,29 @@ def effective_entrypoint( return " ".join(shlex.quote(item) for item in process) +def home_directory(passwd_path: Path, uid: int) -> str | None: + """Return the safe home directory recorded for a container UID.""" + for line in passwd_path.read_text(encoding="utf-8").splitlines(): + fields = line.split(":") + if len(fields) < 7 or fields[2] != str(uid): + continue + home = fields[5] + return home if home.startswith("/") else None + return None + + def main() -> int: + if len(sys.argv) == 4 and sys.argv[1] == "--home": + try: + uid = int(sys.argv[3]) + home = home_directory(Path(sys.argv[2]), uid) + except (OSError, ValueError) as exc: + print(f"{translate('Cannot resolve the container home directory:')} {exc}", file=sys.stderr) + return 1 + if home is None: + return 1 + print(home) + return 0 if len(sys.argv) not in (3, 4): print( f"{translate('Usage:')} {sys.argv[0]} OCI_ARCHIVE COMMAND_JSON [ENTRYPOINT_JSON]", diff --git a/oci/tests/test_runtime_home_contract.py b/oci/tests/test_runtime_home_contract.py new file mode 100644 index 00000000..c14e8e26 --- /dev/null +++ b/oci/tests/test_runtime_home_contract.py @@ -0,0 +1,63 @@ +"""Regression tests for the native OCI PID 1 HOME fallback.""" + +from pathlib import Path +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "remote")) + +import oci_runtime # noqa: E402 + + +class RuntimeHomeResolutionTests(unittest.TestCase): + def passwd(self, content: str) -> Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + path = Path(directory.name) / "passwd" + path.write_text(content, encoding="utf-8") + return path + + def test_root_home_is_resolved_from_passwd(self): + passwd = self.passwd("root:x:0:0:root:/root:/bin/sh\n") + self.assertEqual(oci_runtime.home_directory(passwd, 0), "/root") + + def test_non_root_home_is_resolved_from_passwd(self): + passwd = self.passwd( + "root:x:0:0:root:/root:/bin/sh\n" + "app:x:1001:1001:App user:/srv/app:/sbin/nologin\n" + ) + self.assertEqual(oci_runtime.home_directory(passwd, 1001), "/srv/app") + + def test_unknown_or_unsafe_home_is_not_invented(self): + passwd = self.passwd( + "root:x:0:0:root:relative-home:/bin/sh\n" + "app:x:1001:1001:App user::/sbin/nologin\n" + ) + self.assertIsNone(oci_runtime.home_directory(passwd, 0)) + self.assertIsNone(oci_runtime.home_directory(passwd, 1001)) + self.assertIsNone(oci_runtime.home_directory(passwd, 1234)) + + +class RuntimeHomeInstallerContractTests(unittest.TestCase): + def setUp(self): + self.source = (ROOT / "remote" / "install_oci.sh").read_text(encoding="utf-8") + + def test_explicit_home_is_preserved_and_fallback_uses_effective_uid(self): + self.assertIn("ensure_runtime_home()", self.source) + self.assertIn("lxc.environment.runtime: HOME=", self.source) + self.assertIn('uid=${uid:-0}', self.source) + self.assertIn('home=$(python3 "$OCI_RUNTIME_RESOLVER" --home', self.source) + self.assertIn('set_runtime_environment HOME "$home"', self.source) + + def test_home_fallback_runs_after_runtime_user_is_applied(self): + self.assertIn( + "apply_extra_hosts\napply_installer_profile\nensure_runtime_home\napply_rlimits", + self.source, + ) + + +if __name__ == "__main__": + unittest.main()