#!/usr/bin/env python3 """Resolve the effective OCI process after Compose runtime overrides.""" from __future__ import annotations import json import os import shlex import stat import sys import tarfile from pathlib import Path from typing import Any from oci_ui import translate class RuntimeResolutionError(RuntimeError): pass def _member(archive: tarfile.TarFile, name: str) -> tarfile.TarInfo: for item in archive.getmembers(): if item.name.lstrip("./") == name: return item raise RuntimeResolutionError(f"{translate('Not found in the OCI archive:')} {name}") def _json_member(archive: tarfile.TarFile, name: str) -> dict[str, Any]: source = archive.extractfile(_member(archive, name)) if source is None: raise RuntimeResolutionError(f"{translate('Cannot read')} {name}") value = json.load(source) if not isinstance(value, dict): raise RuntimeResolutionError(f"{translate('Not a JSON object:')} {name}") return value def _blob_name(digest: str) -> str: algorithm, separator, value = digest.partition(":") if separator != ":" or algorithm != "sha256" or len(value) != 64: raise RuntimeResolutionError(f"{translate('Unsupported OCI digest:')} {digest}") return f"blobs/sha256/{value}" def image_entrypoint(archive_path: Path) -> list[str]: with tarfile.open(archive_path, mode="r:*") as archive: index = _json_member(archive, "index.json") manifests = index.get("manifests") or [] if len(manifests) != 1: raise RuntimeResolutionError(translate("The OCI archive does not contain exactly one manifest")) manifest = _json_member(archive, _blob_name(str(manifests[0]["digest"]))) config = _json_member(archive, _blob_name(str(manifest["config"]["digest"]))) entrypoint = (config.get("config") or {}).get("Entrypoint") or [] if isinstance(entrypoint, str): return [entrypoint] if not isinstance(entrypoint, list) or not all(isinstance(item, str) for item in entrypoint): raise RuntimeResolutionError(translate("Invalid OCI Entrypoint")) return entrypoint def _arguments(value: Any, label: str) -> list[str]: if isinstance(value, str): return shlex.split(value) if isinstance(value, list) and all(isinstance(item, str) for item in value): return value raise RuntimeResolutionError(f"{translate('The Compose value must be text or a list:')} {label}") def effective_entrypoint( archive_path: Path, command: Any, compose_entrypoint: Any = None, ) -> str: entrypoint = ( image_entrypoint(archive_path) if compose_entrypoint is None else _arguments(compose_entrypoint, "entrypoint") ) if command is None: arguments: list[str] = [] elif isinstance(command, str): arguments = shlex.split(command) elif isinstance(command, list) and all(isinstance(item, str) for item in command): arguments = command else: raise RuntimeResolutionError(f"{translate('The Compose value must be text or a list:')} command") process = entrypoint + arguments if not process: raise RuntimeResolutionError(translate("The Entrypoint/Cmd combination is empty")) return " ".join(shlex.quote(item) for item in process) def home_directory(passwd_path: Path, uid: int) -> str | None: """Read a regular image passwd file; only an absent UID defaults to /.""" if uid < 0: return None # The caller supplies /etc/passwd. Pin etc so an image # symlink or a rename during lookup cannot redirect the host-side read. directory = os.open(passwd_path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: if not stat.S_ISREG(os.stat(passwd_path.name, dir_fd=directory, follow_symlinks=False).st_mode): return None descriptor = os.open( passwd_path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory, ) try: # Recheck the opened object, not its name; a FIFO replacement # must neither block open() nor reach a read operation. if not stat.S_ISREG(os.fstat(descriptor).st_mode): return None with os.fdopen(descriptor, encoding="utf-8", closefd=False) as stream: content = stream.read() finally: os.close(descriptor) finally: os.close(directory) for line in content.splitlines(): fields = line.split(":") if len(fields) < 3 or fields[2] != str(uid): continue if len(fields) != 7: return None home = fields[5] safe = home.startswith("/") and not any(ord(char) < 32 or ord(char) == 127 for char in home) return home if safe else None return "/" 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]", file=sys.stderr, ) return 2 try: command = json.loads(sys.argv[2]) compose_entrypoint = json.loads(sys.argv[3]) if len(sys.argv) == 4 else None print(effective_entrypoint(Path(sys.argv[1]), command, compose_entrypoint)) except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError, RuntimeResolutionError) as exc: print(f"{translate('Cannot apply the Compose command:')} {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())