""" Helper for running LibreOffice (soffice) in environments where AF_UNIX sockets may be blocked (e.g., sandboxed VMs). Detects the restriction at runtime and applies an LD_PRELOAD shim if needed. Usage: from office.soffice import run_soffice result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) Call soffice through run_soffice, not through subprocess with get_soffice_env(): the env dict carries the shim but names no user profile, and a non-root sandbox cannot bootstrap the default one -- soffice aborts with "User installation could not be completed" and converts nothing. get_soffice_env() stays public for the callers that build their own argv (they must pass -env:UserInstallation too). """ import atexit import contextlib import os import shutil import socket import subprocess import tempfile from collections.abc import Iterable from pathlib import Path # soffice inherits only these. LibreOffice needs a working process environment, # not the caller's credentials, so the env is built from an allowlist rather than # copied: an API key or token exported for the agent must not reach a converter # subprocess just because it happened to be in scope. FORWARDED_ENV_VARS = ( # Locating soffice, its native libraries, and scratch space. "PATH", "HOME", "LD_LIBRARY_PATH", "TMPDIR", "TMP", "TEMP", # Locale decides number, date, and currency formatting in converted output. "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "LC_NUMERIC", "LC_TIME", # soffice writes its profile and cache under these when they are set. "XDG_RUNTIME_DIR", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", # For callers that run against a real display instead of the svp backend. "DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "USER", "LOGNAME", # Windows needs these for process startup, temp files, and DLL resolution. "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "SYSTEMDRIVE", "APPDATA", "LOCALAPPDATA", "USERPROFILE", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMDATA", ) def get_soffice_env() -> dict: """Return a minimal environment for a soffice subprocess. Assembled from FORWARDED_ENV_VARS instead of inheriting the parent environment, so secrets in scope for the caller are not passed to soffice. """ env = {name: os.environ[name] for name in FORWARDED_ENV_VARS if name in os.environ} env["SAL_USE_VCLPLUGIN"] = "svp" if _needs_shim(): shim = _ensure_shim() env["LD_PRELOAD"] = str(shim) return env def run_soffice(args: Iterable[str], **kwargs) -> subprocess.CompletedProcess: args = list(args) with contextlib.ExitStack() as stack: if not any(str(a).startswith("-env:UserInstallation") for a in args): profile = stack.enter_context( tempfile.TemporaryDirectory(prefix="lo_profile_", ignore_cleanup_errors=True) ) args = [f"-env:UserInstallation={Path(profile).as_uri()}"] + args return subprocess.run(["soffice"] + args, env=get_soffice_env(), **kwargs) #: Compiled once per process, not cached on disk between runs. An earlier version #: built the shim at a fixed /tmp/lo_socket_shim.so and reused whatever was already #: there, which let any local user pre-plant a shared object at that predictable #: path and get it LD_PRELOADed into every subsequent soffice run. mkdtemp gives an #: unpredictable directory created 0700 and owned by us, so neither the .c we #: compile nor the .so we load can be swapped by another user. _shim_so: Path | None = None def _needs_shim() -> bool: try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.close() return False except OSError: return True def _ensure_shim() -> Path: global _shim_so if _shim_so is not None and _shim_so.exists(): return _shim_so shim_dir = Path(tempfile.mkdtemp(prefix="lo-shim-")) atexit.register(shutil.rmtree, shim_dir, True) src = shim_dir / "lo_socket_shim.c" so = shim_dir / "lo_socket_shim.so" src.write_text(_SHIM_SOURCE) subprocess.run( ["gcc", "-shared", "-fPIC", "-o", str(so), str(src), "-ldl"], check=True, capture_output=True, ) src.unlink() _shim_so = so return _shim_so _SHIM_SOURCE = r""" #define _GNU_SOURCE #include #include #include #include #include #include #include static int (*real_socket)(int, int, int); static int (*real_socketpair)(int, int, int, int[2]); static int (*real_listen)(int, int); static int (*real_accept)(int, struct sockaddr *, socklen_t *); static int (*real_close)(int); static int (*real_read)(int, void *, size_t); /* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ static int is_shimmed[1024]; static int peer_of[1024]; static int wake_r[1024]; /* accept() blocks reading this */ static int wake_w[1024]; /* close() writes to this */ static int listener_fd = -1; /* FD that received listen() */ __attribute__((constructor)) static void init(void) { real_socket = dlsym(RTLD_NEXT, "socket"); real_socketpair = dlsym(RTLD_NEXT, "socketpair"); real_listen = dlsym(RTLD_NEXT, "listen"); real_accept = dlsym(RTLD_NEXT, "accept"); real_close = dlsym(RTLD_NEXT, "close"); real_read = dlsym(RTLD_NEXT, "read"); for (int i = 0; i < 1024; i++) { peer_of[i] = -1; wake_r[i] = -1; wake_w[i] = -1; } } /* ---- socket ---------------------------------------------------------- */ int socket(int domain, int type, int protocol) { if (domain == AF_UNIX) { int fd = real_socket(domain, type, protocol); if (fd >= 0) return fd; /* socket(AF_UNIX) blocked – fall back to socketpair(). */ int sv[2]; if (real_socketpair(domain, type, protocol, sv) == 0) { if (sv[0] >= 0 && sv[0] < 1024) { is_shimmed[sv[0]] = 1; peer_of[sv[0]] = sv[1]; int wp[2]; if (pipe(wp) == 0) { wake_r[sv[0]] = wp[0]; wake_w[sv[0]] = wp[1]; } } return sv[0]; } errno = EPERM; return -1; } return real_socket(domain, type, protocol); } /* ---- listen ---------------------------------------------------------- */ int listen(int sockfd, int backlog) { if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { listener_fd = sockfd; return 0; } return real_listen(sockfd, backlog); } /* ---- accept ---------------------------------------------------------- */ int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { /* Block until close() writes to the wake pipe. */ if (wake_r[sockfd] >= 0) { char buf; real_read(wake_r[sockfd], &buf, 1); } errno = ECONNABORTED; return -1; } return real_accept(sockfd, addr, addrlen); } /* ---- close ----------------------------------------------------------- */ int close(int fd) { if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { int was_listener = (fd == listener_fd); is_shimmed[fd] = 0; if (wake_w[fd] >= 0) { /* unblock accept() */ char c = 0; write(wake_w[fd], &c, 1); real_close(wake_w[fd]); wake_w[fd] = -1; } if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } if (was_listener) _exit(0); /* conversion done – exit */ } return real_close(fd); } """ if __name__ == "__main__": import sys result = run_soffice(sys.argv[1:]) sys.exit(result.returncode)