diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 310882b..6dd511f 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -45,7 +45,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up JDK 17
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
+ uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0
with:
distribution: 'temurin'
java-version: '17'
@@ -126,8 +126,7 @@ jobs:
if: steps.cache-hook.outputs.cache-hit != 'true'
run: |
sudo rm -f /etc/apt/sources.list.d/*google* /etc/apt/sources.list.d/*chrome*
- sudo apt-get update -o Acquire::Retries=3 -qq
- sudo apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf libc6-dev-arm64-cross libc6-dev-armhf-cross dpkg-dev
+ scripts/ci-apt-install.sh gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf libc6-dev-arm64-cross libc6-dev-armhf-cross dpkg-dev
mkdir -p hook-libs
# The code that is added to static Go programs (gosys/gosys.c), which the 64-bit hook carries
scripts/build-gosys.sh
@@ -349,8 +348,7 @@ jobs:
CORTEX_REQUIRE_GO_TESTS: 1
CORTEX_GO_TEST_VERSIONS: "1.19.13 1.23.12"
run: |
- sudo apt-get update -o Acquire::Retries=3 -qq
- sudo apt-get install -y --no-install-recommends qemu-user gcc-aarch64-linux-gnu libc6-dev-arm64-cross binutils-aarch64-linux-gnu
+ scripts/ci-apt-install.sh qemu-user gcc-aarch64-linux-gnu libc6-dev-arm64-cross binutils-aarch64-linux-gnu
bash tests/scripts/test_go_programs.sh
CORTEX_REQUIRE_QEMU_TESTS=1 bash tests/scripts/test_hook_qemu.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d179b8..6a52606 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,53 @@ What changed in each version of Cortex Terminal, newest first. The release workf
of a GitHub release from the section of its version here. Older versions are described on the
[GitHub releases](https://github.com/ssfuisu/cortex/releases) page.
+## 1.27.5.1 (2026-11-11)
+
+### Fixed
+
+- **XFCE: "Unable to contact settings server. The connection is closed".** Every program in Cortex
+ sees itself as root, but the kernel told a server the app's real user for the program at the other
+ end of a Unix socket. dbus-daemon compares the two and closed every connection to the session bus,
+ so XFCE's settings service could not be reached and the session stopped at that message. The other
+ end is now shown as the user programs see for themselves (SO_PEERCRED, received SCM_CREDENTIALS).
+ The session bus works for other programs too.
+- **XFCE's panel ended at once and the desktop stayed black.** Cortex does not build the database
+ of file types when packages are installed, and GTK cannot load an icon without it. `cortex-desktop`
+ now builds it, and the database of the programs that open files, when the desktop is installed or
+ started and a package has added to them.
+- **"Plugin PulseAudio Plugin unexpectedly left the panel".** A library loaded with dlopen() did
+ not find what one of its dependencies keeps in a directory of its own (libpulse.so.0 and its
+ libpulsecommon). The same kept programs that load libpulse by name from having sound.
+
+- **Every start of the desktop left a PulseAudio behind.** `pulseaudio --kill` looks for a process
+ named pulseaudio and found none (Cortex's programs run as ld.so), so the old one was never
+ stopped and the new one started without its socket: after the desktop was started again its sound
+ went to a daemon nobody listened to. The desktop now keeps track of its PulseAudio itself, and
+ ends the ones older versions left.
+
+- **Programs whose libraries name a directory of their own did not start (#67).** fio ended with
+ "libceph-common.so.2: cannot open shared object file": its librbd.so.1 finds that library through
+ its RUNPATH (/usr/lib/aarch64-linux-gnu/ceph), which ld.so looked for outside Ubuntu's files. The
+ directories that libraries name this way are now collected when ldconfig runs (after packages are
+ installed, as on Linux) into /etc/ld.so.conf.d/zz-cortex-runpath.conf. This also ends the
+ "libpxbackend-1.0.so: cannot open shared object file" lines of GTK programs.
+- **PHP's built-in server: "Cannot create lock - Permission denied (13)" (#69).** PHP keeps the lock
+ of its opcache in a memory file (memfd), and Android does not let an app lock one. A lock that
+ Android refuses on a memfd is now taken on a file of Cortex's own, which the program's children
+ share, so `php -S` and PHP-FPM start.
+- **PHP extensions looked for their files in Android's tree.** PHP loads every extension with
+ RTLD_DEEPBIND, which makes a library call libc directly, past Cortex's library: an extension's
+ absolute paths (a certificate bundle, a database file) were not Ubuntu's. Libraries are now loaded
+ without that flag.
+
+### Changed
+
+- lstat() of a link in /usr/lib (libssl.so.3 -> libssl.so.3.0.0 and the like) no longer reads the
+ link a second time: only a link that can lead into /etc/alternatives is read (#66).
+- XFCE starts with its compositor off (shadows and transparency are drawn by the CPU here); it can
+ be turned on in Settings > Window Manager Tweaks. The desktops install the SVG icon loader.
+- A bug report has the desktop's state and the last lines of its log (`~/.cortex/desktop.log`).
+
## 1.27.4 (2026-11-09)
### Fixed
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0ebb194..e62e6c0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -16,8 +16,8 @@ android {
// from the app's writable data directory (filesDir). Android 10+ (targetSdk >= 29)
// enforces SELinux W^X restrictions preventing execution from writable app data storage.
targetSdk = 28
- versionCode = 12704
- versionName = "1.27.4"
+ versionCode = 12705
+ versionName = "1.27.5.1"
// UbuntuOnDeviceTest (app/src/androidTest), on Firebase Test Lab
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
diff --git a/app/src/main/assets/scripts/cortex-desktop.sh b/app/src/main/assets/scripts/cortex-desktop.sh
index 23095c4..9a5ca25 100644
--- a/app/src/main/assets/scripts/cortex-desktop.sh
+++ b/app/src/main/assets/scripts/cortex-desktop.sh
@@ -23,10 +23,10 @@ DEFAULT_SIZE="1280x720"
LIGHT_PACKAGES="tigervnc-standalone-server tigervnc-tools dbus-x11 xauth x11-xserver-utils x11-utils
xfonts-base fonts-dejavu-core openbox obconf python3-xdg tint2 pcmanfm lxterminal mousepad lxappearance
-adwaita-icon-theme-full pulseaudio pulseaudio-utils xdg-utils"
+adwaita-icon-theme-full librsvg2-common pulseaudio pulseaudio-utils xdg-utils"
XFCE_PACKAGES="tigervnc-standalone-server tigervnc-tools dbus-x11 xauth x11-xserver-utils x11-utils
xfonts-base fonts-dejavu-core xfce4 xfce4-terminal thunar mousepad
-adwaita-icon-theme-full pulseaudio pulseaudio-utils xdg-utils"
+adwaita-icon-theme-full librsvg2-common pulseaudio pulseaudio-utils xdg-utils"
die() {
echo "cortex-desktop: $*" >&2
@@ -98,6 +98,39 @@ free_display() {
return 1
}
+# The real program behind one that Cortex does not run when packages are installed (its stand-in
+# does nothing, and the hook answers for a program of that name itself)
+real_tool() {
+ if [ -x "$R/usr/bin/$1.distrib" ]; then
+ echo "$R/usr/bin/$1.distrib"
+ elif [ -x "$R/usr/bin/$1" ] && mkdir -p "$STATE" && ln -sf "$R/usr/bin/$1" "$STATE/$1.real" 2>/dev/null; then
+ echo "$STATE/$1.real"
+ else
+ return 1
+ fi
+}
+
+# The databases of file types (/usr/share/mime) and of the programs that open them. Cortex leaves
+# them out when packages are installed: they take time and a terminal has no use for them. But GTK
+# cannot load a single icon without the first (XFCE's panel ended at once, its desktop stayed
+# black), and a file manager does not know what opens a file without the second. They are built
+# here: the first (a few seconds) when it is missing or a package added to or took from it (the
+# directory of its sources changed: a file dpkg unpacks keeps the date it has in the package, so the
+# files' own dates say nothing), the second (a moment) every time.
+refresh_databases() {
+ local dir real
+ dir="$R/usr/share/mime"
+ if [ -d "$dir/packages" ] && { [ ! -f "$dir/mime.cache" ] || [ -n "$(find "$dir/packages" -newer "$dir/mime.cache" -print -quit 2>/dev/null)" ]; }; then
+ if real="$(real_tool update-mime-database)"; then
+ "$real" "$dir" >/dev/null 2>&1 || echo "cortex-desktop: update-mime-database failed" >&2
+ fi
+ fi
+ dir="$R/usr/share/applications"
+ if [ -d "$dir" ] && real="$(real_tool update-desktop-database)"; then
+ "$real" "$dir" >/dev/null 2>&1 || echo "cortex-desktop: update-desktop-database failed" >&2
+ fi
+}
+
installed_kind() {
local kind
kind="$(conf_value DESKTOP)"
@@ -133,6 +166,7 @@ cmd_install() {
as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $packages ||
die "the packages could not be installed"
if [ "$firefox" = 1 ]; then install_firefox || echo "cortex-desktop: Firefox was not installed (see above)." >&2; fi
+ refresh_databases
set_conf DESKTOP "$kind"
echo "The desktop is installed. Open it from the app's menu (Desktop), or run: cortex-desktop start"
}
@@ -254,19 +288,53 @@ show_mounts=0
EOF
}
+# Ends the desktop's PulseAudio. "pulseaudio --kill" does not find it here: it looks for a process
+# named pulseaudio, and Cortex's programs run as ld.so with their name as an argument. So it was
+# never stopped, and every start of the desktop left one more behind, the new one without its socket
+# (the old one still held it). Ours are known by the pid kept at their start and by their socket.
+stop_audio() {
+ local pid i theirs="module-native-protocol-unix socket=$STATE/pulse.sock"
+ pid="$(cat "$STATE/pulse.pid" 2>/dev/null)"
+ pid_is "$pid" pulseaudio && kill "$pid" 2>/dev/null
+ command -v pkill >/dev/null 2>&1 && pkill -f "$theirs" 2>/dev/null
+ # all of them gone before the socket is taken away: one that ends later removes it as it goes,
+ # which would be the next one's
+ for i in $(seq 1 30); do
+ if ! pid_is "$pid" pulseaudio && ! { command -v pgrep >/dev/null 2>&1 && pgrep -f "$theirs" >/dev/null 2>&1; }; then break; fi
+ sleep 0.1
+ done
+ if pid_is "$pid" pulseaudio; then kill -9 "$pid" 2>/dev/null; fi
+ command -v pkill >/dev/null 2>&1 && pkill -9 -f "$theirs" 2>/dev/null
+ rm -f "$STATE/pulse.pid" "$STATE/pulse.server" "$STATE/pulse.sock"
+}
+
start_audio() {
command -v pulseaudio >/dev/null 2>&1 || return 0
- local fifo="$STATE/audio.fifo"
+ local fifo="$STATE/audio.fifo" pid i
[ -p "$fifo" ] || { rm -f "$fifo"; mkfifo -m 600 "$fifo" 2>/dev/null; } || return 0
- pulseaudio --kill >/dev/null 2>&1
+ stop_audio
# No shared memory between PulseAudio and its programs (they talk over the socket), no realtime
- # priority (Android does not give it), and one output: the pipe the app reads and plays
- pulseaudio --daemonize=yes --exit-idle-time=-1 --disallow-exit --disable-shm=yes \
+ # priority (Android does not give it), and one output: the pipe the app reads and plays. Not as
+ # its own daemon and without its pid file (see stop_audio): in a session of its own, like the X server.
+ setsid pulseaudio --daemonize=no --use-pid-file=no --exit-idle-time=-1 --disallow-exit --disable-shm=yes \
--realtime=no --high-priority=no --log-target=file:"$LOG_DIR/pulseaudio.log" -n \
-L "module-native-protocol-unix socket=$STATE/pulse.sock auth-anonymous=1" \
-L "module-pipe-sink file=$fifo format=s16le rate=44100 channels=2 sink_name=android" \
- -L "module-always-sink" >/dev/null 2>&1 || return 0
- echo "unix:$STATE/pulse.sock" >"$STATE/pulse.server"
+ -L "module-always-sink" /dev/null 2>&1 9>&- &
+ pid=$!
+ echo "$pid" >"$STATE/pulse.pid"
+ # its socket is there within a moment; a desktop without sound is still a desktop
+ for i in $(seq 1 50); do
+ [ -S "$STATE/pulse.sock" ] && break
+ pid_alive "$pid" || break
+ sleep 0.1
+ done
+ if [ -S "$STATE/pulse.sock" ]; then
+ echo "unix:$STATE/pulse.sock" >"$STATE/pulse.server"
+ else
+ # not one that runs on without anyone knowing its socket
+ stop_audio
+ fi
}
cmd_start() {
@@ -325,21 +393,43 @@ cmd_start() {
start_audio
if ! start_session "$display"; then
stop_processes
+ stop_audio
die "the desktop did not start (log: $LOG)"
fi
echo "The desktop is running: DISPLAY=:$display (programs started from the terminal open on it)."
}
+# XFCE's window manager blends the windows together itself (shadows, transparency) unless told
+# otherwise, and without a GPU the CPU does that for every change on the screen. Off the first time;
+# the file is the user's from then on (Settings > Window Manager Tweaks > Compositor).
+ensure_xfce_defaults() {
+ local dir="${HOME}/.config/xfce4/xfconf/xfce-perchannel-xml"
+ [ -e "$dir/xfwm4.xml" ] && return 0
+ mkdir -p "$dir" || return 0
+ cat >"$dir/xfwm4.xml" <<'EOF'
+
+
+
+
+
+
+
+EOF
+}
+
# The desktop's session on the X server of display $1; fails when it ends at once
start_session() {
local display="$1" pulse="" current="openbox"
- [ "$(conf_value DESKTOP)" = xfce ] || ensure_openbox_autostart
+ refresh_databases
+ if [ "$(conf_value DESKTOP)" = xfce ]; then ensure_xfce_defaults; else ensure_openbox_autostart; fi
[ -f "$STATE/pulse.server" ] && pulse="$(cat "$STATE/pulse.server")"
[ "$(conf_value DESKTOP)" = xfce ] && current="XFCE"
(
cd "$HOME" || exit 1
unset SESSION_MANAGER DBUS_SESSION_BUS_ADDRESS
- export DISPLAY=":$display" XDG_SESSION_TYPE=x11 XDG_CURRENT_DESKTOP="$current"
+ # NO_AT_BRIDGE: there is no accessibility bus here, and every GTK program waited for it and
+ # wrote a warning
+ export DISPLAY=":$display" XDG_SESSION_TYPE=x11 XDG_CURRENT_DESKTOP="$current" NO_AT_BRIDGE=1
[ -n "$pulse" ] && export PULSE_SERVER="$pulse"
# shellcheck disable=SC2046
exec setsid dbus-launch --exit-with-session $(session_command)
@@ -375,8 +465,8 @@ cmd_stop() {
take_lock
local stopped=0
stop_processes && stopped=1
- command -v pulseaudio >/dev/null 2>&1 && pulseaudio --kill >/dev/null 2>&1
- rm -f "$STATE/pulse.server" "$SOCKET"
+ stop_audio
+ rm -f "$SOCKET"
if [ "$stopped" = 1 ]; then echo "The desktop was stopped."; else echo "The desktop was not running."; fi
}
diff --git a/app/src/main/cpp/cortex-hook.c b/app/src/main/cpp/cortex-hook.c
index 4d83c93..874f9ab 100644
--- a/app/src/main/cpp/cortex-hook.c
+++ b/app/src/main/cpp/cortex-hook.c
@@ -80,3 +80,4 @@
#include "hook/net.c"
#include "hook/sockets.c"
#include "hook/sysv_shm.c"
+#include "hook/memfd_locks.c"
diff --git a/app/src/main/cpp/hook/exec.c b/app/src/main/cpp/hook/exec.c
index 51c5872..26960e1 100644
--- a/app/src/main/cpp/hook/exec.c
+++ b/app/src/main/cpp/hook/exec.c
@@ -139,11 +139,13 @@ static int run_execve(orig_execve_f_type f, const char *path, char *const argv[]
return f(path, argv, envp);
}
-// ldconfig does nothing in Cortex (its cache is for paths that ld.so opens without the hook), but the
+// ldconfig writes no cache in Cortex (its cache is for paths that ld.so opens without the hook), but the
// exec code reads /etc/ld.so.conf(.d) again when /etc/ld.so.cache changes: "sudo ldconfig" after an
// edit of a .conf file marks it changed, as the real ldconfig would by writing it.
static void ldconfig_mark_cache(void) {
if (g_cortex_root[0] == '\0') return;
+ // what the real one does for its cache: the libraries are read (their RUNPATH), see runpath_conf_refresh()
+ runpath_conf_refresh();
char path[PATH_MAX];
size_t n = cortex_append(path, sizeof(path), 0, g_cortex_root, g_cortex_root_len);
cortex_append(path, sizeof(path), n, "/etc/ld.so.cache", 16);
diff --git a/app/src/main/cpp/hook/exec_probe.c b/app/src/main/cpp/hook/exec_probe.c
index 94afe50..7b51bad 100644
--- a/app/src/main/cpp/hook/exec_probe.c
+++ b/app/src/main/cpp/hook/exec_probe.c
@@ -411,7 +411,9 @@ static int elf_read_dynamic(int fd, const unsigned char *h, ssize_t n, char *out
}
at += (uint64_t)got - (uint64_t)got % ent;
}
- if (!has_strtab || (!has_runpath && !has_rpath)) return 0;
+ // without a RUNPATH the names of what it needs are still wanted, when asked for
+ int has_path = has_runpath || has_rpath;
+ if (!has_strtab || (!has_path && !(needed && nneeded > 0))) return 0;
uint64_t str_index = has_runpath ? runpath : rpath;
// DT_STRTAB is an address: the file offset is that of the PT_LOAD segment holding it
@@ -439,6 +441,7 @@ static int elf_read_dynamic(int fd, const unsigned char *h, ssize_t n, char *out
(*needed_count)++;
}
}
+ if (!has_path) return 0;
ssize_t len = pread(fd, out, outsz - 1, (off_t)(strtab_off + str_index));
if (len <= 0) {
out[0] = '\0';
@@ -555,10 +558,95 @@ static int ldso_program_index(char *const argv[], int *has_library_path) {
// opens without the hook: PulseAudio's modules (RUNPATH /usr/lib/aarch64-linux-gnu/pulseaudio) and many
// plugins did not load. Before the real dlopen(), the dependencies found in the rootfs copies of those
// directories are loaded by their full path, depth first; ld.so then finds them loaded, by their name.
+// The RUNPATH can be a dependency's own: a plugin that needs libpulse.so.0, which ld.so finds, which
+// needs libpulsecommon in its RUNPATH (XFCE's panel asked to restart its PulseAudio plugin forever).
+// So the dependencies ld.so finds itself are walked as well, and only what lies in a RUNPATH is loaded.
#define DLOPEN_PRELOAD_DEPTH 6
-static int dlopen_preload_deps(const char *path, void *(*orig_dlopen)(const char *, int), int depth) {
+// dir/name of the first directory of `dirs` ("a:b:c") that has `name`, into out. Returns 1 when found.
+static int find_in_lib_dirs(const char *dirs, const char *name, char *out, size_t outsz) {
+ size_t name_len = strlen(name);
+ for (const char *p = dirs; p && *p;) {
+ const char *end = strchr(p, ':');
+ size_t len = end ? (size_t)(end - p) : strlen(p);
+ if (len > 0 && len + 1 + name_len < outsz) {
+ memcpy(out, p, len);
+ out[len] = '/';
+ memcpy(out + len + 1, name, name_len + 1);
+ int probe_fd = (int)syscall(SYS_openat, AT_FDCWD, out, O_PATH | O_CLOEXEC);
+ if (probe_fd >= 0) {
+ syscall(SYS_close, probe_fd);
+ return 1;
+ }
+ }
+ if (!end) break;
+ p = end + 1;
+ }
+ return 0;
+}
+
+// The directories ld.so looks for a library of the program in (LD_LIBRARY_PATH or the --library-path
+// it was started with, then its own), as "a:b:c" in out. Returns its length, 0 when it is not known.
+static size_t ldso_search_dirs(void *(*orig_dlopen)(const char *, int), char *out, size_t outsz) {
+ out[0] = '\0';
+ void *self = orig_dlopen(NULL, RTLD_LAZY);
+ if (!self) return 0;
+ size_t used = 0;
+ Dl_serinfo size;
+ if (dlinfo(self, RTLD_DI_SERINFOSIZE, &size) == 0 && size.dls_size >= sizeof(size) && size.dls_size < (1u << 20)) {
+ // zeroed, and every name checked to lie in it: the list can change between the two calls
+ // (another thread loading a library), and what the second leaves unwritten is then not read
+ Dl_serinfo *info = (Dl_serinfo *)calloc(1, size.dls_size);
+ if (info) {
+ info->dls_size = size.dls_size;
+ info->dls_cnt = size.dls_cnt;
+ const char *lo = (const char *)info, *hi = lo + size.dls_size;
+ if (dlinfo(self, RTLD_DI_SERINFO, info) == 0) {
+ unsigned cnt = info->dls_cnt < size.dls_cnt ? info->dls_cnt : size.dls_cnt;
+ for (unsigned i = 0; i < cnt; i++) {
+ if ((const char *)&info->dls_serpath[i + 1] > hi) break;
+ const char *dir = info->dls_serpath[i].dls_name;
+ if (!dir || dir < lo || dir >= hi) continue;
+ size_t len = strnlen(dir, (size_t)(hi - dir));
+ if (len == 0 || len == (size_t)(hi - dir) || dir[0] != '/' || used + len + 2 > outsz) continue;
+ if (used) out[used++] = ':';
+ memcpy(out + used, dir, len);
+ used += len;
+ out[used] = '\0';
+ }
+ }
+ free(info);
+ }
+ }
+ dlclose(self);
+ return used;
+}
+
+// What one walk carries along: ldso_search_dirs(), read when first needed, and the libraries it
+// has read already (several of them need the same ones: each is read once)
+#define DLOPEN_WALK_SEEN 96
+struct dlopen_walk {
+ char *search;
+ size_t search_size;
+ int search_read;
+ uint64_t seen[DLOPEN_WALK_SEEN];
+ int nseen;
+};
+
+static uint64_t dlopen_walk_hash(const char *path) {
+ uint64_t h = 1469598103934665603ULL; // FNV-1a
+ for (const unsigned char *p = (const unsigned char *)path; *p; p++) h = (h ^ *p) * 1099511628211ULL;
+ return h ? h : 1;
+}
+
+static int dlopen_preload_deps_in(const char *path, void *(*orig_dlopen)(const char *, int), int depth, struct dlopen_walk *walk) {
if (depth > DLOPEN_PRELOAD_DEPTH || !path || path[0] != '/') return 0;
+ uint64_t hash = dlopen_walk_hash(path);
+ for (int i = 0; i < walk->nseen; i++) {
+ if (walk->seen[i] == hash) return 0;
+ }
+ if (walk->nseen == DLOPEN_WALK_SEEN) return 0; // a tree this wide is not walked to its end
+ walk->seen[walk->nseen++] = hash;
int fd = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC);
if (fd < 0) return 0;
unsigned char hdr[64];
@@ -566,14 +654,15 @@ static int dlopen_preload_deps(const char *path, void *(*orig_dlopen)(const char
char runpath[1024];
char needed[4096];
int count = 0;
+ runpath[0] = '\0';
+ // 1: a DT_RUNPATH, 2: a DT_RPATH, 0: neither
int kind = n > 0 ? elf_read_dynamic(fd, hdr, n, runpath, sizeof(runpath), needed, sizeof(needed), &count) : 0;
syscall(SYS_close, fd);
- if (!kind || count == 0) return 0;
+ if (count == 0) return 0;
// the RUNPATH's directories, mapped into the rootfs ($ORIGIN from this library's own place)
char dirs[PATH_MAX * 2];
dirs[0] = '\0';
- size_t used = append_runpath_dirs(dirs, sizeof(dirs), 0, runpath, path);
- if (used == 0) return 0;
+ append_runpath_dirs(dirs, sizeof(dirs), 0, runpath, path);
int loaded = 0;
const char *name = needed;
for (int i = 0; i < count; i++, name += strlen(name) + 1) {
@@ -583,29 +672,60 @@ static int dlopen_preload_deps(const char *path, void *(*orig_dlopen)(const char
dlclose(already);
continue;
}
- for (const char *p = dirs; *p;) {
- const char *end = strchr(p, ':');
- size_t len = end ? (size_t)(end - p) : strlen(p);
- char candidate[PATH_MAX];
- if (len + 1 + strlen(name) < sizeof(candidate)) {
- memcpy(candidate, p, len);
- candidate[len] = '/';
- strcpy(candidate + len + 1, name);
- int probe_fd = (int)syscall(SYS_openat, AT_FDCWD, candidate, O_PATH | O_CLOEXEC);
- if (probe_fd >= 0) {
- syscall(SYS_close, probe_fd);
- dlopen_preload_deps(candidate, orig_dlopen, depth + 1);
- if (orig_dlopen(candidate, RTLD_LAZY | RTLD_LOCAL)) loaded++;
- break;
- }
+ // In ld.so's order: a DT_RPATH comes before the directories it looks in for every library
+ // (LD_LIBRARY_PATH or --library-path), a DT_RUNPATH after them. A library that is in both
+ // must be the one ld.so takes, or the wrong one of the two would be loaded under its name.
+ char candidate[PATH_MAX];
+ int found = 0, in_runpath = 0;
+ if (kind == 2) found = in_runpath = find_in_lib_dirs(dirs, name, candidate, sizeof(candidate));
+ if (!found) {
+ if (!walk->search_read) {
+ ldso_search_dirs(orig_dlopen, walk->search, walk->search_size);
+ walk->search_read = 1;
}
- if (!end) break;
- p = end + 1;
+ found = find_in_lib_dirs(walk->search, name, candidate, sizeof(candidate));
}
+ if (!found && kind != 2) found = in_runpath = find_in_lib_dirs(dirs, name, candidate, sizeof(candidate));
+ if (!found) continue;
+ // what it needs may be in its own RUNPATH, whoever finds it
+ loaded += dlopen_preload_deps_in(candidate, orig_dlopen, depth + 1, walk);
+ // one that ld.so finds itself is left to it
+ if (in_runpath && orig_dlopen(candidate, RTLD_LAZY | RTLD_LOCAL)) loaded++;
}
return loaded;
}
+// `name` is the library asked for: a path (in the rootfs already), or a name that ld.so looks for
+static int dlopen_preload_deps(const char *name, void *(*orig_dlopen)(const char *, int)) {
+ if (!name || !name[0]) return 0;
+ struct dlopen_walk *walk = (struct dlopen_walk *)calloc(1, sizeof(*walk) + PATH_MAX * 4);
+ if (!walk) return 0;
+ walk->search = (char *)(walk + 1);
+ walk->search_size = PATH_MAX * 4;
+ int loaded = 0;
+ char found[PATH_MAX];
+ if (name[0] != '/' && !strchr(name, '/')) {
+ ldso_search_dirs(orig_dlopen, walk->search, walk->search_size);
+ walk->search_read = 1;
+ name = find_in_lib_dirs(walk->search, name, found, sizeof(found)) ? found : NULL;
+ } else if (name[0] != '/') {
+ // "./plugins/x.so": from the directory the program is in (the kernel's name for it, which
+ // is in the rootfs already), only to read the file; ld.so is asked with the caller's words
+ long cwd_len = syscall(SYS_getcwd, found, sizeof(found));
+ size_t at = cwd_len > 0 ? strnlen(found, sizeof(found)) : 0;
+ if (at > 0 && found[0] == '/' && at + 1 + strlen(name) < sizeof(found)) {
+ found[at] = '/';
+ strcpy(found + at + 1, name);
+ name = found;
+ } else {
+ name = NULL;
+ }
+ }
+ if (name && name[0] == '/') loaded = dlopen_preload_deps_in(name, orig_dlopen, 0, walk);
+ free(walk);
+ return loaded;
+}
+
// ---- /etc/ld.so.conf -------------------------------------------------------------------------
// The library directories of /etc/ld.so.conf and the files it includes (/etc/ld.so.conf.d/*.conf),
// mapped into the rootfs. On Linux, ldconfig turns them into /etc/ld.so.cache, which ld.so reads.
@@ -797,6 +917,194 @@ static void ldconf_parse(char *out, size_t outsz) {
ldconf_parse_file("/etc/ld.so.conf", out, outsz, &used, 0, &includes);
}
+// ---- the RUNPATH of libraries ------------------------------------------------------------------
+// A library's own DT_RUNPATH is where ld.so looks for what that library needs: librbd.so.1 names
+// /usr/lib/aarch64-linux-gnu/ceph, where its libceph-common.so.2 is. ld.so opens that directory
+// itself, without the hook, so it looked in Android's tree and fio ended with "libceph-common.so.2:
+// cannot open shared object file". The exec cannot read every library a program will load (that
+// would be paid by every program started), so the directories are collected the way ldconfig
+// collects its cache: when ldconfig runs (after packages are installed) and when the list is not
+// there yet, from the libraries in the directories ld.so searches, into a file of
+// /etc/ld.so.conf.d that the exec reads like the others (after them: "zz"). A directory that is
+// searched already, or does not exist, is not listed.
+#define RUNPATH_CONF "/etc/ld.so.conf.d/zz-cortex-runpath.conf"
+#define RUNPATH_CONF_NEW "/etc/ld.so.conf.d/.zz-cortex-runpath.new"
+#define RUNPATH_CONF_MAX 4096
+#define RUNPATH_SCAN_DIRS 32
+
+static const char k_runpath_conf_head[] =
+ "# Written by Cortex when ldconfig runs: the directories that libraries name in their RUNPATH,\n"
+ "# which ld.so would look for outside Ubuntu's files. Written again each time: not for editing.\n";
+
+struct runpath_scan {
+ char all[PATH_MAX * 2]; // the directories to read, real paths
+ char base[PATH_MAX * 2]; // those of them that are searched without this file
+ char old[RUNPATH_CONF_MAX + sizeof(k_runpath_conf_head)];
+ char text[RUNPATH_CONF_MAX + sizeof(k_runpath_conf_head)];
+ size_t text_len;
+ char dents[8192];
+ uint64_t seen[RUNPATH_SCAN_DIRS][2]; // device and inode of the directories read (/lib is /usr/lib)
+ int nseen;
+};
+
+// list without its entry dir[0..len)
+static void lib_dir_list_remove(char *list, const char *dir, size_t len) {
+ size_t out = 0;
+ for (const char *p = list; *p;) {
+ const char *end = strchr(p, ':');
+ size_t n = end ? (size_t)(end - p) : strlen(p);
+ if (!(n == len && memcmp(p, dir, len) == 0)) {
+ if (out > 0) list[out++] = ':';
+ memmove(list + out, p, n);
+ out += n;
+ }
+ if (!end) break;
+ p = end + 1;
+ }
+ list[out] = '\0';
+}
+
+// One directory of a RUNPATH, as a path inside Ubuntu: a line of the file when it is new
+static void runpath_scan_add(struct runpath_scan *scan, char *logical) {
+ size_t len = strlen(logical);
+ while (len > 1 && logical[len - 1] == '/') logical[--len] = '\0';
+ if (logical[0] != '/' || len < 2 || len >= PATH_MAX || strpbrk(logical, "$:\n# \t")) return;
+ char rw_buf[PATH_MAX];
+ const char *real = rewrite_path_in_root(logical, rw_buf, sizeof(rw_buf));
+ size_t real_len = strlen(real);
+ if (strcmp(real, logical) == 0 || lib_dir_list_has(scan->base, real, real_len)) return;
+ // a whole line of what was found so far
+ for (const char *p = scan->text; (p = strstr(p, logical)) != NULL; p += len) {
+ if (p > scan->text && p[-1] == '\n' && p[len] == '\n') return;
+ }
+ if (scan->text_len + len + 2 > sizeof(scan->text)) return;
+ int fd = (int)syscall(SYS_openat, AT_FDCWD, real, O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (fd < 0) return;
+ syscall(SYS_close, fd);
+ memcpy(scan->text + scan->text_len, logical, len);
+ scan->text_len += len;
+ scan->text[scan->text_len++] = '\n';
+ scan->text[scan->text_len] = '\0';
+}
+
+// The libraries of one directory (real path): the RUNPATH of each
+static void runpath_scan_dir(struct runpath_scan *scan, const char *dir, size_t dir_len) {
+ char real[PATH_MAX];
+ if (dir_len == 0 || dir_len >= sizeof(real)) return;
+ memcpy(real, dir, dir_len);
+ real[dir_len] = '\0';
+ int fd = (int)syscall(SYS_openat, AT_FDCWD, real, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (fd < 0) return;
+ struct statx stx;
+ if (syscall(SYS_statx, fd, "", AT_EMPTY_PATH, STATX_INO, &stx) == 0) {
+ uint64_t dev = ((uint64_t)stx.stx_dev_major << 32) | stx.stx_dev_minor;
+ for (int i = 0; i < scan->nseen; i++) {
+ if (scan->seen[i][0] == dev && scan->seen[i][1] == stx.stx_ino) {
+ syscall(SYS_close, fd);
+ return;
+ }
+ }
+ if (scan->nseen < RUNPATH_SCAN_DIRS) {
+ scan->seen[scan->nseen][0] = dev;
+ scan->seen[scan->nseen][1] = stx.stx_ino;
+ scan->nseen++;
+ }
+ }
+ for (;;) {
+ long n = syscall(SYS_getdents64, fd, scan->dents, sizeof(scan->dents));
+ if (n <= 0) break;
+ for (long at = 0; at < n;) {
+ unsigned short reclen;
+ memcpy(&reclen, scan->dents + at + 16, sizeof(reclen));
+ if (reclen == 0) break;
+ unsigned char type = (unsigned char)scan->dents[at + 18];
+ const char *name = scan->dents + at + 19;
+ at += reclen;
+ // the files themselves: a link names one of them (or one of another directory read)
+ if ((type != DT_REG && type != DT_UNKNOWN) || !strstr(name, ".so")) continue;
+ int lib = (int)syscall(SYS_openat, fd, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
+ if (lib < 0) continue;
+ unsigned char hdr[64];
+ char runpath[1024];
+ ssize_t got = pread(lib, hdr, sizeof(hdr), 0);
+ int kind = got > 0 ? elf_read_runpath(lib, hdr, got, runpath, sizeof(runpath)) : 0;
+ syscall(SYS_close, lib);
+ if (!kind) continue;
+ for (char *p = runpath; p && *p;) {
+ char *end = strchr(p, ':');
+ if (end) *end = '\0';
+ // $ORIGIN is the library's own place, which ld.so knows: only the absolute ones
+ char logical[PATH_MAX];
+ if (p[0] == '/' && strlen(p) < sizeof(logical)) {
+ snprintf(logical, sizeof(logical), "%s", p);
+ normalize_path(logical);
+ runpath_scan_add(scan, logical);
+ }
+ p = end ? end + 1 : NULL;
+ }
+ }
+ }
+ syscall(SYS_close, fd);
+}
+
+// Whether the list is not there and can be written: a system whose /etc/ld.so.conf.d is gone or
+// cannot be written is not read through by every program that starts
+static int runpath_conf_missing(void) {
+ char rw_buf[PATH_MAX], dir_buf[PATH_MAX];
+ const char *real = rewrite_path_in_root(RUNPATH_CONF, rw_buf, sizeof(rw_buf));
+ if (syscall(SYS_faccessat, AT_FDCWD, real, F_OK) == 0) return 0;
+ const char *dir = rewrite_path_in_root("/etc/ld.so.conf.d", dir_buf, sizeof(dir_buf));
+ return syscall(SYS_faccessat, AT_FDCWD, dir, W_OK | X_OK) == 0;
+}
+
+// Reads the libraries and writes the file when what it should hold changed
+static void runpath_conf_refresh(void) {
+ if (g_cortex_root[0] == '\0') return;
+ // not on the stack: this runs in the child of a fork that is about to exec
+ struct runpath_scan *scan = (struct runpath_scan *)mmap(NULL, sizeof(*scan), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+ if (scan == MAP_FAILED) return;
+ ldconf_parse(scan->all, sizeof(scan->all));
+ size_t used = strlen(scan->all);
+ append_rootfs_lib_dirs_missing_from(scan->all, sizeof(scan->all), used, used > 0, scan->all);
+ memcpy(scan->base, scan->all, sizeof(scan->base));
+ ssize_t old_len = ldconf_read(RUNPATH_CONF, scan->old, sizeof(scan->old));
+ if (old_len > 0) {
+ // what the file itself adds does not count as searched: it would drop out of the new one
+ char *save = NULL;
+ char copy[sizeof(scan->old)];
+ memcpy(copy, scan->old, (size_t)old_len + 1);
+ for (char *line = strtok_r(copy, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) {
+ if (line[0] != '/') continue;
+ char rw_buf[PATH_MAX];
+ const char *real = rewrite_path_in_root(line, rw_buf, sizeof(rw_buf));
+ lib_dir_list_remove(scan->base, real, strlen(real));
+ }
+ }
+ memcpy(scan->text, k_runpath_conf_head, sizeof(k_runpath_conf_head));
+ scan->text_len = sizeof(k_runpath_conf_head) - 1;
+ for (const char *p = scan->all; *p;) {
+ const char *end = strchr(p, ':');
+ size_t len = end ? (size_t)(end - p) : strlen(p);
+ runpath_scan_dir(scan, p, len);
+ if (!end) break;
+ p = end + 1;
+ }
+ if (old_len < 0 || (size_t)old_len != scan->text_len || memcmp(scan->old, scan->text, scan->text_len) != 0) {
+ char new_buf[PATH_MAX], final_buf[PATH_MAX];
+ const char *tmp = rewrite_path_in_root(RUNPATH_CONF_NEW, new_buf, sizeof(new_buf));
+ const char *final = rewrite_path_in_root(RUNPATH_CONF, final_buf, sizeof(final_buf));
+ int fd = (int)syscall(SYS_openat, AT_FDCWD, tmp, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
+ if (fd >= 0) {
+ ssize_t written = (ssize_t)syscall(SYS_write, fd, scan->text, scan->text_len);
+ syscall(SYS_close, fd);
+ if (written != (ssize_t)scan->text_len || syscall(SYS_renameat, AT_FDCWD, tmp, AT_FDCWD, final) != 0) {
+ syscall(SYS_unlinkat, AT_FDCWD, tmp, 0);
+ }
+ }
+ }
+ munmap(scan, sizeof(*scan));
+}
+
// The ld.so.conf directories into out (':'-separated, "" when there are none). Uses the copy of an
// earlier call while the files have not changed: the parent finds it when it first forks, and its
// children only compare the three times. Never waits for the lock: a child forked while another
@@ -813,6 +1121,11 @@ static void ldconf_dirs(char *out, size_t outsz) {
return;
}
if (!g_ldconf.valid || memcmp(&g_ldconf.sig, &sig, sizeof(sig)) != 0 || strcmp(g_ldconf.root, g_cortex_root) != 0) {
+ // a system set up before the list of the libraries' RUNPATH existed, or one without it
+ if (runpath_conf_missing()) {
+ runpath_conf_refresh();
+ ldconf_signature(&sig);
+ }
ldconf_parse(g_ldconf.dirs, sizeof(g_ldconf.dirs));
g_ldconf.sig = sig;
snprintf(g_ldconf.root, sizeof(g_ldconf.root), "%s", g_cortex_root);
diff --git a/app/src/main/cpp/hook/files.c b/app/src/main/cpp/hook/files.c
index 8abe64f..1b65838 100644
--- a/app/src/main/cpp/hook/files.c
+++ b/app/src/main/cpp/hook/files.c
@@ -58,7 +58,19 @@ static int refused_in_repaired_dir(int dirfd, const char *name) {
return fixed;
}
-static int dlopen_preload_deps(const char *path, void *(*orig_dlopen)(const char *, int), int depth);
+static int dlopen_preload_deps(const char *name, void *(*orig_dlopen)(const char *, int));
+
+// RTLD_DEEPBIND makes a library, and the libraries it brings along, look for a function in their
+// own dependencies (libc) before the program's, which is before this hook: none of their calls
+// would be seen. PHP loads every extension that way, so an extension's files were looked for in
+// Android's tree and the memfd lock of its opcache was never reached (memfd_locks.c). Without the
+// flag they are bound like any other library.
+static int dlopen_flags_for_hook(int flags) {
+#ifdef RTLD_DEEPBIND
+ flags &= ~RTLD_DEEPBIND;
+#endif
+ return flags;
+}
// Hook dlopen and dlmopen
void *dlopen(const char *filename, int flags) {
@@ -69,11 +81,15 @@ void *dlopen(const char *filename, int flags) {
char buf[PATH_MAX];
const char *target = rewrite_path(filename, buf, sizeof(buf));
+ flags = dlopen_flags_for_hook(flags);
void *handle = orig_dlopen(target, flags);
- if (!handle && target[0] == '/' && !(flags & RTLD_NOLOAD)) {
- // Its dependencies may be in its RUNPATH, which ld.so looked for outside the rootfs
+ if (!handle && !(flags & RTLD_NOLOAD)) {
+ // Its dependencies, or theirs, may be in a RUNPATH, which ld.so looked for outside the rootfs
+ // Asked again in any case: with what was loaded, or for dlerror() to have ld.so's own words
+ // (the look for the dependencies replaced them)
int saved = errno;
- if (dlopen_preload_deps(target, orig_dlopen, 0) > 0) handle = orig_dlopen(target, flags);
+ dlopen_preload_deps(target, orig_dlopen);
+ handle = orig_dlopen(target, flags);
if (!handle) errno = saved;
}
return handle;
@@ -86,7 +102,7 @@ void *dlmopen(Lmid_t lmid, const char *filename, int flags) {
char buf[PATH_MAX];
const char *target = rewrite_path(filename, buf, sizeof(buf));
- return orig_dlmopen ? orig_dlmopen(lmid, target, flags) : NULL;
+ return orig_dlmopen ? orig_dlmopen(lmid, target, dlopen_flags_for_hook(flags)) : NULL;
}
// Hook open
diff --git a/app/src/main/cpp/hook/memfd_locks.c b/app/src/main/cpp/hook/memfd_locks.c
new file mode 100644
index 0000000..d4b824c
--- /dev/null
+++ b/app/src/main/cpp/hook/memfd_locks.c
@@ -0,0 +1,238 @@
+// Part of libcortex-hook.so, included by cortex-hook.c (one translation unit, so the
+// parts share their static functions and are built with the same command as before).
+//
+// File locks on a memfd. Android's SELinux policy gives an app's memory files (memfd_create) no
+// "lock" permission, so fcntl(F_SETLK...) and flock() on one fail with EACCES, which they never do
+// on Linux. PHP keeps the lock of its opcache in a memfd: "php -S" ended at once with "Cannot
+// create lock - Permission denied (13)". A lock that is refused that way is taken on a file of the
+// hook's own instead: /tmp/.cortex-memfd-locks/INODE, named after the memfd, so that every process
+// that has the memfd (forked before or after its first lock) and every descriptor of it reach the
+// same file and still exclude each other. As with a real file, closing the memfd gives its locks up.
+
+#ifndef CORTEX_HOST_TEST
+
+#define MEMFD_LOCK_SLOTS 32
+#define MEMFD_LOCK_DIR "/tmp/.cortex-memfd-locks"
+
+// One memfd of this process whose locks are on a file. No lock guards the table (a fork while
+// another thread held one would leave it held in the child for good): a place is taken by its
+// `ino` (0: free), and is used once `shadow` is set (the file's descriptor plus one).
+static struct {
+ uint64_t ino;
+ uint64_t dev;
+ int shadow;
+} g_memfd_locks[MEMFD_LOCK_SLOTS];
+static int g_memfd_locks_used; // whether any was ever taken: close() looks only then
+
+static int fcntl_is_lock_cmd(int cmd) {
+ switch (cmd) {
+ case F_GETLK:
+ case F_SETLK:
+ case F_SETLKW:
+#if defined(F_GETLK64) && F_GETLK64 != F_GETLK
+ case F_GETLK64:
+ case F_SETLK64:
+ case F_SETLKW64:
+#endif
+#ifdef F_OFD_GETLK
+ case F_OFD_GETLK:
+ case F_OFD_SETLK:
+ case F_OFD_SETLKW:
+#endif
+ return 1;
+ default:
+ return 0;
+ }
+}
+
+// Whether fd is a memfd (its name in /proc is "/memfd:NAME (deleted)"), with its inode and device
+static int memfd_identity(int fd, uint64_t *ino, uint64_t *dev) {
+ char link[64], target[64];
+ snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
+ ssize_t len = (ssize_t)syscall(SYS_readlinkat, AT_FDCWD, link, target, sizeof(target) - 1);
+ if (len < 7 || memcmp(target, "/memfd:", 7) != 0) return 0;
+ struct statx stx;
+ if (syscall(SYS_statx, fd, "", AT_EMPTY_PATH, STATX_INO, &stx) != 0 || stx.stx_ino == 0) return 0;
+ *ino = stx.stx_ino;
+ *dev = ((uint64_t)stx.stx_dev_major << 32) | stx.stx_dev_minor;
+ return 1;
+}
+
+static int memfd_lock_find(uint64_t ino, uint64_t dev) {
+ for (int i = 0; i < MEMFD_LOCK_SLOTS; i++) {
+ if (__atomic_load_n(&g_memfd_locks[i].ino, __ATOMIC_ACQUIRE) == ino && g_memfd_locks[i].dev == dev) {
+ int shadow = __atomic_load_n(&g_memfd_locks[i].shadow, __ATOMIC_ACQUIRE);
+ if (shadow > 0) return shadow - 1;
+ }
+ }
+ return -1;
+}
+
+// The files of memfds that are gone with the last boot (a memfd does not outlive it)
+static void memfd_lock_sweep(const char *dir) {
+ struct timespec up, now;
+ if (clock_gettime(CLOCK_BOOTTIME, &up) != 0 || clock_gettime(CLOCK_REALTIME, &now) != 0) return;
+ int64_t booted = (int64_t)now.tv_sec - (int64_t)up.tv_sec - 60;
+ int fd = (int)syscall(SYS_openat, AT_FDCWD, dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (fd < 0) return;
+ char dents[2048];
+ for (;;) {
+ long n = syscall(SYS_getdents64, fd, dents, sizeof(dents));
+ if (n <= 0) break;
+ for (long at = 0; at < n;) {
+ unsigned short reclen;
+ memcpy(&reclen, dents + at + 16, sizeof(reclen));
+ if (reclen == 0) break;
+ const char *name = dents + at + 19;
+ at += reclen;
+ if (name[0] == '.') continue;
+ struct statx stx;
+ if (syscall(SYS_statx, fd, name, AT_SYMLINK_NOFOLLOW, STATX_MTIME, &stx) == 0 && (int64_t)stx.stx_mtime.tv_sec < booted) {
+ syscall(SYS_unlinkat, fd, name, 0);
+ }
+ }
+ }
+ syscall(SYS_close, fd);
+}
+
+// The file that stands in for the locks of the memfd fd, opened when this process has none yet;
+// -1 without one (not a memfd, no room, no file: the caller keeps the refusal)
+static int memfd_lock_shadow(int fd, int (*orig_fcntl)(int, int, ...)) {
+ uint64_t ino, dev;
+ if (g_cortex_root[0] == '\0' || !memfd_identity(fd, &ino, &dev)) return -1;
+ int shadow = memfd_lock_find(ino, dev);
+ if (shadow >= 0) return shadow;
+
+ char dir[PATH_MAX], path[PATH_MAX];
+ size_t n = cortex_append(dir, sizeof(dir), 0, g_cortex_root, g_cortex_root_len);
+ cortex_append(dir, sizeof(dir), n, MEMFD_LOCK_DIR, sizeof(MEMFD_LOCK_DIR) - 1);
+ snprintf(path, sizeof(path), "%s/%llu", dir, (unsigned long long)ino);
+ int made = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600);
+ if (made < 0 && errno == ENOENT) {
+ syscall(SYS_mkdirat, AT_FDCWD, dir, 0700);
+ made = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600);
+ }
+ if (made < 0) return -1;
+ // out of the way of the numbers a program gives its own files (and of a loop that closes them)
+ int high = orig_fcntl(made, F_DUPFD_CLOEXEC, 700);
+ if (high >= 0) {
+ syscall(SYS_close, made);
+ made = high;
+ }
+ for (int i = 0; i < MEMFD_LOCK_SLOTS; i++) {
+ uint64_t free_slot = 0;
+ if (__atomic_compare_exchange_n(&g_memfd_locks[i].ino, &free_slot, ino, 0, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) {
+ g_memfd_locks[i].dev = dev;
+ __atomic_store_n(&g_memfd_locks[i].shadow, made + 1, __ATOMIC_RELEASE);
+ if (!__atomic_exchange_n(&g_memfd_locks_used, 1, __ATOMIC_ACQ_REL)) memfd_lock_sweep(dir);
+ return made;
+ }
+ }
+ // no place left: refused, rather than a lock that the next close of some memfd would give up
+ syscall(SYS_close, made);
+ return -1;
+}
+
+// close() of fd: a process that closes a file gives up its locks on it, whichever descriptor it used
+static void memfd_lock_closing(int fd) {
+ uint64_t ino, dev;
+ if (!memfd_identity(fd, &ino, &dev)) return;
+ for (int i = 0; i < MEMFD_LOCK_SLOTS; i++) {
+ if (__atomic_load_n(&g_memfd_locks[i].ino, __ATOMIC_ACQUIRE) != ino || g_memfd_locks[i].dev != dev) continue;
+ int shadow = __atomic_exchange_n(&g_memfd_locks[i].shadow, 0, __ATOMIC_ACQ_REL);
+ if (shadow > 0) syscall(SYS_close, shadow - 1);
+ __atomic_store_n(&g_memfd_locks[i].ino, 0, __ATOMIC_RELEASE);
+ }
+}
+
+// For tests on Linux, where a memfd can be locked: CORTEX_MEMFD_LOCKS=deny makes the hook act as if not
+static int memfd_locks_denied_for_test(int fd) {
+ static int state; // 0: not read, 1: no, 2: yes
+ int s = __atomic_load_n(&state, __ATOMIC_RELAXED);
+ if (s == 0) {
+ const char *v = getenv("CORTEX_MEMFD_LOCKS");
+ s = (v && strcmp(v, "deny") == 0) ? 2 : 1;
+ __atomic_store_n(&state, s, __ATOMIC_RELAXED);
+ }
+ uint64_t ino, dev;
+ return s == 2 && memfd_identity(fd, &ino, &dev);
+}
+
+static int cortex_fcntl(int fd, int cmd, void *arg, const char *symbol, int (**orig_slot)(int, int, ...)) {
+ int (*orig)(int, int, ...) = __atomic_load_n(orig_slot, __ATOMIC_RELAXED);
+ if (!orig) {
+ orig = (int (*)(int, int, ...))dlsym(RTLD_NEXT, symbol);
+ if (!orig) orig = (int (*)(int, int, ...))dlsym(RTLD_NEXT, "fcntl");
+ if (!orig) {
+ errno = ENOSYS;
+ return -1;
+ }
+ __atomic_store_n(orig_slot, orig, __ATOMIC_RELAXED);
+ }
+ if (!fcntl_is_lock_cmd(cmd)) return orig(fd, cmd, arg);
+ int ret;
+ if (memfd_locks_denied_for_test(fd)) {
+ ret = -1;
+ errno = EACCES;
+ } else {
+ ret = orig(fd, cmd, arg);
+ }
+ if (ret == -1 && errno == EACCES) {
+ int shadow = memfd_lock_shadow(fd, orig);
+ if (shadow >= 0) return orig(shadow, cmd, arg);
+ errno = EACCES;
+ }
+ return ret;
+}
+
+// The third argument is read as glibc's own fcntl() reads it: always, as a pointer. On 32-bit ARM
+// the headers name this function __fcntl_time64 (see CORTEX_TIME64_NAME), which is what Ubuntu's
+// programs call there.
+int fcntl(int fd, int cmd, ...) {
+ static int (*orig_fcntl)(int, int, ...) = NULL;
+ va_list ap;
+ va_start(ap, cmd);
+ void *arg = va_arg(ap, void *);
+ va_end(ap);
+ return cortex_fcntl(fd, cmd, arg, CORTEX_TIME64_NAME("__fcntl_time64", "fcntl"), &orig_fcntl);
+}
+
+#ifndef __USE_FILE_OFFSET64
+// What programs built with 64-bit file offsets call (glibc 2.28 and later). With _FILE_OFFSET_BITS=64
+// (the 32-bit ARM build) the headers already export the definition above under that name.
+int fcntl64(int fd, int cmd, ...) {
+ static int (*orig_fcntl64)(int, int, ...) = NULL;
+ va_list ap;
+ va_start(ap, cmd);
+ void *arg = va_arg(ap, void *);
+ va_end(ap);
+ return cortex_fcntl(fd, cmd, arg, "fcntl64", &orig_fcntl64);
+}
+#endif
+
+int flock(int fd, int operation) {
+ static int (*orig_flock)(int, int) = NULL;
+ static int (*orig_fcntl)(int, int, ...) = NULL;
+ if (!orig_flock) orig_flock = (int (*)(int, int))dlsym(RTLD_NEXT, "flock");
+ if (!orig_flock) {
+ errno = ENOSYS;
+ return -1;
+ }
+ int ret;
+ if (memfd_locks_denied_for_test(fd)) {
+ ret = -1;
+ errno = EACCES;
+ } else {
+ ret = orig_flock(fd, operation);
+ }
+ if (ret == -1 && errno == EACCES) {
+ if (!orig_fcntl) orig_fcntl = (int (*)(int, int, ...))dlsym(RTLD_NEXT, CORTEX_TIME64_NAME("__fcntl_time64", "fcntl"));
+ if (!orig_fcntl) orig_fcntl = (int (*)(int, int, ...))dlsym(RTLD_NEXT, "fcntl");
+ int shadow = orig_fcntl ? memfd_lock_shadow(fd, orig_fcntl) : -1;
+ if (shadow >= 0) return orig_flock(shadow, operation);
+ errno = EACCES;
+ }
+ return ret;
+}
+
+#endif /* !CORTEX_HOST_TEST */
diff --git a/app/src/main/cpp/hook/net.c b/app/src/main/cpp/hook/net.c
index 7e46590..1149b9a 100644
--- a/app/src/main/cpp/hook/net.c
+++ b/app/src/main/cpp/hook/net.c
@@ -237,11 +237,16 @@ static void clear_dns_redirect(int fd) {
pthread_mutex_unlock(&g_dns_redirect_mutex);
}
+// memfd_locks.c: a memfd whose locks are on a file of the hook's gives them up when it is closed
+static int g_memfd_locks_used;
+static void memfd_lock_closing(int fd);
+
int close(int fd) {
static int (*orig_close)(int) = NULL;
if (!orig_close) orig_close = (int (*)(int))dlsym(RTLD_NEXT, "close");
// The ARM translator's memory file (privileged.c): left open, as if it were closed
if (g_kept_fd_count > 0 && is_kept_fd(fd)) return 0;
+ if (__atomic_load_n(&g_memfd_locks_used, __ATOMIC_RELAXED)) memfd_lock_closing(fd);
clear_dns_redirect(fd);
set_chosen_port_fd(fd, 0);
return orig_close ? orig_close(fd) : -1;
diff --git a/app/src/main/cpp/hook/paths.c b/app/src/main/cpp/hook/paths.c
index d8bf6f4..50fcd6d 100644
--- a/app/src/main/cpp/hook/paths.c
+++ b/app/src/main/cpp/hook/paths.c
@@ -155,14 +155,18 @@ static int usrmerge_alt_path(const char *sub, char *alt, size_t altsz) {
// Whether postprocess_readlink() below can change a link's target of length target_len, read
// through linkpath: only a target that starts with the rootfs path (at least that long) or a relative
-// one into /etc/alternatives (at least "../alternatives", 15 characters, from a directory next to it;
-// any "../" under /etc/alternatives itself). lstat() of a link reads it only then, to report its
-// rewritten length.
+// one into /etc/alternatives. From a directory next to it under /etc that is at least
+// "../alternatives" (15 characters), from /etc/alternatives itself any "../", and from anywhere else
+// at least "../etc/alternatives" (19: the link is one directory below /, deeper ones need more).
+// lstat() of a link reads it only then, to report its rewritten length; the links of /usr/lib
+// (libssl.so.3 -> libssl.so.3.0.0 and the like, 15 to 18 characters) are not read a second time.
static int readlink_result_may_change(const char *linkpath, long long target_len) {
if (g_cortex_root_len > 0 && target_len >= (long long)g_cortex_root_len) return 1;
if (!linkpath || linkpath[0] != '/') return 0;
if (strncmp(linkpath, "/etc/alternatives", 17) == 0) return target_len >= 2;
- return target_len >= 15;
+ // "/usr/../etc/x", "//etc/x": where the link is is not read off the start of such a path
+ if (strncmp(linkpath, "/etc/", 5) == 0 || strstr(linkpath, "/..") || strstr(linkpath, "//")) return target_len >= 15;
+ return target_len >= 19;
}
// Helper for path normalization
diff --git a/app/src/main/cpp/hook/sockets.c b/app/src/main/cpp/hook/sockets.c
index 2d4505b..0c3a6ac 100644
--- a/app/src/main/cpp/hook/sockets.c
+++ b/app/src/main/cpp/hook/sockets.c
@@ -201,6 +201,38 @@ static int real_scm_credentials(const struct msghdr *msg, struct msghdr *mod, ch
return 1;
}
+// On 32-bit ARM this library is built, as all of Ubuntu's armhf packages are, with a 64-bit time_t:
+// the headers then give getsockopt(), recvmmsg() and fcntl() other names (__getsockopt64...), and
+// the functions here are those. The one to pass a call on to is the one of the same name: the plain
+// one takes the old layout of a timeout.
+#if defined(__USE_TIME_BITS64) && !defined(__LP64__)
+#define CORTEX_TIME64_NAME(name64, name) name64
+#else
+#define CORTEX_TIME64_NAME(name64, name) name
+#endif
+
+// The other end of a Unix socket as the kernel names it (SO_PEERCRED, a received SCM_CREDENTIALS)
+// is the app's real user, while every program here sees itself as root (getuid() is 0). A server
+// that compares the two refuses its own user: dbus-daemon closed every connection to the session
+// bus, and XFCE stopped at "Unable to contact settings server: The connection is closed". The
+// app's own ids are shown as the ids programs see for themselves; any other id is left as it is.
+static void peer_cred_as_seen_here(struct ucred *cred) {
+ if (cred->uid == (uid_t)call_orig_syscall(CORTEX_NR_REAL_GETUID, 0, 0, 0, 0, 0, 0)) cred->uid = getuid();
+ if (cred->gid == (gid_t)call_orig_syscall(CORTEX_NR_REAL_GETGID, 0, 0, 0, 0, 0, 0)) cred->gid = getgid();
+}
+
+// The SCM_CREDENTIALS of a message that came in, shown the same way
+static void received_creds_as_seen_here(struct msghdr *msg) {
+ if (!msg || !msg->msg_control || msg->msg_controllen < sizeof(struct cmsghdr)) return;
+ for (struct cmsghdr *c = CMSG_FIRSTHDR(msg); c; c = CMSG_NXTHDR(msg, c)) {
+ if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_CREDENTIALS || c->cmsg_len < CMSG_LEN(sizeof(struct ucred))) continue;
+ struct ucred cred;
+ memcpy(&cred, CMSG_DATA(c), sizeof(cred));
+ peer_cred_as_seen_here(&cred);
+ memcpy(CMSG_DATA(c), &cred, sizeof(cred));
+ }
+}
+
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags) {
static ssize_t (*orig_sendmsg)(int, const struct msghdr *, int) = NULL;
if (!orig_sendmsg) orig_sendmsg = (ssize_t (*)(int, const struct msghdr *, int))dlsym(RTLD_NEXT, "sendmsg");
@@ -256,9 +288,45 @@ ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags) {
restore_dns_source(sockfd, (struct sockaddr *)msg->msg_name, cap, &msg->msg_namelen);
errno = saved_errno;
}
+ if (ret >= 0) received_creds_as_seen_here(msg);
return ret;
}
+int recvmmsg(int sockfd, struct mmsghdr *msgvec, unsigned int vlen, int flags, struct timespec *timeout) {
+ static int (*orig_recvmmsg)(int, struct mmsghdr *, unsigned int, int, struct timespec *) = NULL;
+ if (!orig_recvmmsg) orig_recvmmsg = (int (*)(int, struct mmsghdr *, unsigned int, int, struct timespec *))dlsym(RTLD_NEXT, CORTEX_TIME64_NAME("__recvmmsg64", "recvmmsg"));
+ if (!orig_recvmmsg) {
+ errno = ENOSYS;
+ return -1;
+ }
+ int ret = orig_recvmmsg(sockfd, msgvec, vlen, flags, timeout);
+ for (int i = 0; i < ret && msgvec; i++) received_creds_as_seen_here(&msgvec[i].msg_hdr);
+ return ret;
+}
+
+static void peer_cred_option_as_seen_here(int ret, int level, int optname, void *optval, socklen_t *optlen) {
+ if (ret == 0 && level == SOL_SOCKET && optname == SO_PEERCRED && optval && optlen && *optlen >= sizeof(struct ucred)) {
+ struct ucred cred;
+ memcpy(&cred, optval, sizeof(cred));
+ peer_cred_as_seen_here(&cred);
+ memcpy(optval, &cred, sizeof(cred));
+ }
+}
+
+int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen) {
+ static int (*orig_getsockopt)(int, int, int, void *, socklen_t *) = NULL;
+ if (!orig_getsockopt) orig_getsockopt = (int (*)(int, int, int, void *, socklen_t *))dlsym(RTLD_NEXT, CORTEX_TIME64_NAME("__getsockopt64", "getsockopt"));
+ if (!orig_getsockopt) orig_getsockopt = (int (*)(int, int, int, void *, socklen_t *))dlsym(RTLD_NEXT, "getsockopt");
+ if (!orig_getsockopt) {
+ errno = ENOSYS;
+ return -1;
+ }
+ int ret = orig_getsockopt(sockfd, level, optname, optval, optlen);
+ peer_cred_option_as_seen_here(ret, level, optname, optval, optlen);
+ return ret;
+}
+
+
int getpeername(int sockfd, struct sockaddr *addr, socklen_t *addrlen) {
static int (*orig_getpeername)(int, struct sockaddr *, socklen_t *) = NULL;
if (!orig_getpeername) orig_getpeername = (int (*)(int, struct sockaddr *, socklen_t *))dlsym(RTLD_NEXT, "getpeername");
diff --git a/app/src/main/java/org/cortex/terminal/BugReport.kt b/app/src/main/java/org/cortex/terminal/BugReport.kt
index 19a174f..97dc8a6 100644
--- a/app/src/main/java/org/cortex/terminal/BugReport.kt
+++ b/app/src/main/java/org/cortex/terminal/BugReport.kt
@@ -18,12 +18,15 @@ import java.util.Locale
/**
* A text file with what helps to find a problem: the app's and the phone's versions, the state of
- * the Ubuntu system and root mode, and the app's own log (Android lets an app read only its own).
+ * the Ubuntu system and root mode, the desktop's log, and the app's own log (Android lets an app
+ * read only its own).
* Settings > Send a bug report shares it with an app the user picks; nothing is sent otherwise.
*/
object BugReport {
private const val LOG_LINES = 4000
private const val LOG_LIMIT = 2_000_000
+ private const val DESKTOP_LOG_LINES = 300
+ private const val DESKTOP_LOG_BYTES = 256 * 1024
fun write(context: Context): File {
val dir = File(context.cacheDir, "reports")
@@ -83,9 +86,21 @@ object BugReport {
"hook library" to File(root, "usr/lib/libcortex-hook.so").let { if (it.isFile) "${it.length()} bytes" else "missing" },
"missing" to BootstrapManager.missingEssentials(root).joinToString().ifEmpty { "nothing" }
)
- return format(sections) + "\n== Log (newest $LOG_LINES lines)\n" + appLog()
+ sections["Desktop"] = listOf(
+ "installed" to (org.cortex.terminal.desktop.DesktopAddon.installedKind(context) ?: "no"),
+ "running" to org.cortex.terminal.desktop.DesktopAddon.isRunning(context).toString()
+ )
+ // What the X server and the desktop's session wrote: the only place their errors are
+ val desktopLog = readTail(File(Environment.getHomeDir(context), ".cortex/desktop.log"), DESKTOP_LOG_BYTES)
+ return format(sections) +
+ (if (desktopLog.isNullOrBlank()) "" else "== Desktop log (newest $DESKTOP_LOG_LINES lines)\n${lastLines(desktopLog, DESKTOP_LOG_LINES)}\n\n") +
+ "== Log (newest $LOG_LINES lines)\n" + appLog()
}
+ /** The last [count] lines of a text. */
+ internal fun lastLines(text: String, count: Int): String =
+ text.trimEnd('\n').lines().takeLast(count).joinToString("\n")
+
/** The sections as "== Name" headings with "key: value" lines; missing values are marked. */
internal fun format(sections: Map>>): String = buildString {
for ((name, entries) in sections) {
@@ -110,6 +125,24 @@ object BugReport {
null
}
+ /** The end of a file, at most [maxBytes] of it (a log grows for as long as its session runs). */
+ internal fun readTail(file: File, maxBytes: Int): String? = try {
+ if (!file.isFile) {
+ null
+ } else {
+ java.io.RandomAccessFile(file, "r").use { raf ->
+ val length = raf.length()
+ val take = minOf(length, maxBytes.toLong()).toInt()
+ raf.seek(length - take)
+ val bytes = ByteArray(take)
+ raf.readFully(bytes)
+ String(bytes, Charsets.UTF_8)
+ }
+ }
+ } catch (e: Exception) {
+ null
+ }
+
private fun appLog(): String = try {
val process = ProcessBuilder("logcat", "-d", "-v", "time", "-t", LOG_LINES.toString())
.redirectErrorStream(true)
diff --git a/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt b/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt
index b407efe..e6721b9 100644
--- a/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt
+++ b/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt
@@ -12,7 +12,7 @@ import java.util.zip.GZIPInputStream
object BootstrapManager {
private const val TAG = "BootstrapManager"
- const val CURRENT_BOOTSTRAP_VERSION = 12703
+ const val CURRENT_BOOTSTRAP_VERSION = 12705
fun initializeFileSystem(context: Context) {
val root = Environment.getCortexRoot(context)
diff --git a/app/src/test/java/org/cortex/terminal/BugReportTest.kt b/app/src/test/java/org/cortex/terminal/BugReportTest.kt
index ba48fd1..65df302 100644
--- a/app/src/test/java/org/cortex/terminal/BugReportTest.kt
+++ b/app/src/test/java/org/cortex/terminal/BugReportTest.kt
@@ -48,4 +48,24 @@ class BugReportTest {
assertEquals(2, BugReport.packageCount(status))
assertEquals(0, BugReport.packageCount(""))
}
+
+ @Test
+ fun lastLines_keepsTheNewestLines() {
+ assertEquals("c\nd", BugReport.lastLines("a\nb\nc\nd\n", 2))
+ assertEquals("a\nb", BugReport.lastLines("a\nb", 5))
+ assertEquals("", BugReport.lastLines("", 3))
+ }
+
+ @Test
+ fun readTail_readsOnlyTheEndOfALongFile() {
+ val file = java.io.File.createTempFile("desktop", ".log")
+ try {
+ file.writeText("a".repeat(1000) + "end")
+ assertEquals("aaend", BugReport.readTail(file, 5))
+ assertEquals(1003, BugReport.readTail(file, 5000)?.length)
+ assertNull(BugReport.readTail(java.io.File(file.path + ".missing"), 5))
+ } finally {
+ file.delete()
+ }
+ }
}
diff --git a/scripts/ci-apt-install.sh b/scripts/ci-apt-install.sh
new file mode 100755
index 0000000..cf4f80c
--- /dev/null
+++ b/scripts/ci-apt-install.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+# ci-apt-install.sh PACKAGE...: installs packages on a CI runner. The runner's own mirror
+# (azure.archive.ubuntu.com) did not resolve for the whole of two runs in a row, archive.ubuntu.com
+# for a third, and apt then tried every file for twenty minutes before giving up. When a mirror
+# cannot be reached, or the install from it fails, the next one is used: Ubuntu's archive, then
+# kernel.org's mirror of it (apt checks the signatures of what it gets from any of them).
+set -u
+
+MIRRORS="archive.ubuntu.com/ubuntu mirrors.edge.kernel.org/ubuntu"
+
+use_mirror() { # host/path
+ echo "ci-apt-install: using $1"
+ if [ -f /etc/apt/apt-mirrors.txt ]; then
+ printf 'http://%s/\tpriority:1\n' "$1" | sudo tee /etc/apt/apt-mirrors.txt >/dev/null
+ fi
+ sudo sed -i -E "s#^URIs: https?://[^ ]*ubuntu[^ ]*#URIs: http://$1/#; s#https?://[a-z.]*archive\.ubuntu\.com/ubuntu/?#http://$1/#g; s#https?://security\.ubuntu\.com/ubuntu/?#http://$1/#g" \
+ /etc/apt/sources.list.d/*.sources /etc/apt/sources.list 2>/dev/null
+ return 0
+}
+
+resolves() {
+ getent hosts "${1%%/*}" >/dev/null 2>&1
+}
+
+install() {
+ sudo apt-get update -o Acquire::Retries=2 -qq &&
+ sudo apt-get install -y --no-install-recommends -o Acquire::Retries=2 "$@"
+}
+
+# the mirror the runner is set up with, unless its name does not resolve now
+if ! grep -qs azure.archive.ubuntu.com /etc/apt/apt-mirrors.txt /etc/apt/sources.list.d/*.sources /etc/apt/sources.list ||
+ resolves azure.archive.ubuntu.com; then
+ install "$@" && exit 0
+fi
+for mirror in $MIRRORS; do
+ resolves "$mirror" || { echo "ci-apt-install: ${mirror%%/*} does not resolve"; continue; }
+ use_mirror "$mirror"
+ install "$@" && exit 0
+done
+echo "ci-apt-install: no mirror worked" >&2
+exit 1
diff --git a/tests/e2e/in-terminal.sh b/tests/e2e/in-terminal.sh
index 3aac092..b452029 100644
--- a/tests/e2e/in-terminal.sh
+++ b/tests/e2e/in-terminal.sh
@@ -178,8 +178,14 @@ if [ -z "$NO_APT" ]; then
check apt-update apt-get update
expect hello "Hello, world!" hello
expect python3 "4" python3 -c 'print(2 + 2)'
+ # A lock on a memfd, which Android's SELinux policy refuses an app (PHP's opcache keeps its lock so)
+ expect memfd-lock "locked" python3 -c 'import fcntl, os
+fd = os.memfd_create("lock")
+fcntl.lockf(fd, fcntl.LOCK_EX)
+fcntl.flock(fd, fcntl.LOCK_EX)
+print("locked")'
else
- for name in apt-install apt-update hello python3; do skip "$name" "no packages on this emulator"; done
+ for name in apt-install apt-update hello python3 memfd-lock; do skip "$name" "no packages on this emulator"; done
fi
# cortex-api
diff --git a/tests/native/test_cortex_native.c b/tests/native/test_cortex_native.c
index c902780..5824138 100644
--- a/tests/native/test_cortex_native.c
+++ b/tests/native/test_cortex_native.c
@@ -1946,6 +1946,12 @@ static void test_readlink_result_may_change(void) {
ASSERT_EQ(readlink_result_may_change("/usr/bin/awk", 21), 1, "an absolute link may point into the rootfs path");
ASSERT_EQ(readlink_result_may_change("/usr/bin/awk", (long long)g_cortex_root_len), 1, "a link as long as the rootfs path");
ASSERT_EQ(readlink_result_may_change("/usr/bin/x", 14), 0, "shorter than ../alternatives and the rootfs path");
+ ASSERT_EQ(readlink_result_may_change("/usr/lib/libssl.so.3", 15), 0, "libssl.so.3.0.0: no way into /etc/alternatives from outside /etc");
+ ASSERT_EQ(readlink_result_may_change("/usr/x", 18), 0, "one short of ../etc/alternatives");
+ ASSERT_EQ(readlink_result_may_change("/usr/x", 19), 1, "../etc/alternatives from one directory below /");
+ ASSERT_EQ(readlink_result_may_change("/etc/x", 15), 1, "a link in /etc is read from 15 characters on");
+ ASSERT_EQ(readlink_result_may_change("/usr/../etc/foo/x", 15), 1, "a path with .. may be in /etc");
+ ASSERT_EQ(readlink_result_may_change("//etc/foo/x", 15), 1, "and so may one with doubled slashes");
ASSERT_EQ(readlink_result_may_change("/etc/foo/x", 15), 1, "../alternatives from next to /etc/alternatives may be normalized");
ASSERT_EQ(readlink_result_may_change("/usr/bin/x", 19), 1, "../etc/alternatives may be normalized");
ASSERT_EQ(readlink_result_may_change("/etc/alternatives/awk", 2), 1, "any ../ link in /etc/alternatives");
diff --git a/tests/scripts/test_hook_qemu.sh b/tests/scripts/test_hook_qemu.sh
index 2840355..044cfa0 100755
--- a/tests/scripts/test_hook_qemu.sh
+++ b/tests/scripts/test_hook_qemu.sh
@@ -444,6 +444,38 @@ rm -f "$work/root/etc/ld.so.cache"
run "$work/run_ldconfig" >/dev/null
check "ldconfig marks /etc/ld.so.cache changed" test -e "$work/root/etc/ld.so.cache"
+# The RUNPATH of a library the program needs (fio: librbd.so.1 names /usr/lib//ceph, where its
+# libceph-common.so.2 is). The exec does not read the libraries: their RUNPATH directories are in a
+# file of /etc/ld.so.conf.d, written when it is missing and when ldconfig runs.
+rp_conf="$work/root/etc/ld.so.conf.d/zz-cortex-runpath.conf"
+mkdir -p "$work/root/usr/lib/aarch64-linux-gnu/ceph" "$work/root/usr/lib/aarch64-linux-gnu/origin/sub"
+build_chain() { # name, the directory of the inner library, the RUNPATH the outer one names it by
+ echo "int $1_inner(void) { return 5; }" | aarch64-linux-gnu-gcc -shared -fPIC -w -Wl,-soname,lib$1inner.so.2 \
+ -o "$2/lib$1inner.so.2" -x c - || { echo "lib$1inner did not build"; exit 1; }
+ printf 'int %s_inner(void);\nint %s_outer(void) { return %s_inner() + 1; }\n' "$1" "$1" "$1" |
+ aarch64-linux-gnu-gcc -shared -fPIC -w -Wl,-soname,lib$1outer.so.1 -o "$work/root/usr/lib/aarch64-linux-gnu/lib$1outer.so.1" \
+ -x c - -x none "$2/lib$1inner.so.2" "-Wl,--enable-new-dtags,-rpath,$3" || { echo "lib$1outer did not build"; exit 1; }
+ printf '#include \nint %s_outer(void);\nint main(void) { printf("%s %%d\\n", %s_outer()); return 0; }\n' "$1" "$1" "$1" |
+ aarch64-linux-gnu-gcc -O2 -w -o "$work/root/usr/bin/use_$1" -x c - -x none "$work/root/usr/lib/aarch64-linux-gnu/lib$1outer.so.1" \
+ -Wl,--allow-shlib-undefined || { echo "use_$1 did not build"; exit 1; }
+}
+build_chain rbd "$work/root/usr/lib/aarch64-linux-gnu/ceph" /usr/lib/aarch64-linux-gnu/ceph
+rm -f "$rp_conf"
+out=$(run "$work/start_private" /usr/bin/use_rbd)
+check "a program finds what one of its libraries has in that library's RUNPATH" test "$out" = "rbd 6"
+[ "$out" = "rbd 6" ] || echo " got: $out"
+check "the directory is listed in /etc/ld.so.conf.d, and only what is not searched already" \
+ sh -c 'grep -qx "/usr/lib/aarch64-linux-gnu/ceph" "$1" && ! grep -q "android\|conflib" "$1"' sh "$rp_conf"
+# one installed later counts after ldconfig, as on Linux
+build_chain late "$work/root/usr/lib/aarch64-linux-gnu/origin/sub" /usr/lib/aarch64-linux-gnu/origin/sub
+before=$(run "$work/start_private" /usr/bin/use_late)
+run "$work/run_ldconfig" >/dev/null
+out=$(run "$work/start_private" /usr/bin/use_late)
+check "ldconfig reads the libraries again: one installed since is found after it" \
+ sh -c '[ "$1" != "late 6" ] && [ "$2" = "late 6" ] && grep -qx "/usr/lib/aarch64-linux-gnu/origin/sub" "$3" && grep -qx "/usr/lib/aarch64-linux-gnu/ceph" "$3"' \
+ sh "$before" "$out" "$rp_conf"
+[ "$out" = "late 6" ] || echo " got: $out (before ldconfig: $before)"
+
# System V shared memory (X's MIT-SHM): Android's seccomp filter blocks it, the hook keeps the segments
# in files. One process makes a segment, another one (started anew, so nothing is inherited) attaches it.
mkdir -p "$work/root/usr/lib" && cp "$work/hook.so" "$work/root/usr/lib/libcortex-hook.so" # children get the hook too
@@ -566,19 +598,67 @@ out=$(run "$work/load_plugin")
check "dlopen() of a library whose dependency is in its RUNPATH" test "$out" = "plugin 100"
[ "$out" = "plugin 100" ] || echo " got: $out"
+# The RUNPATH of a dependency: a plugin without one needs a library that ld.so finds, which needs
+# another in its own RUNPATH (XFCE's PulseAudio plugin: libpulse.so.0, then libpulsecommon). And a
+# library asked for by its name alone (dlopen("libpulse.so.0"), as Firefox and SDL do).
+mkdir -p "$work/root/usr/lib/aarch64-linux-gnu/deepdeps" "$work/root/usr/lib/mid"
+echo 'int deep_value(void) { return 40; }' | aarch64-linux-gnu-gcc -shared -fPIC -w -Wl,-soname,libdeep.so.1 \
+ -o "$work/root/usr/lib/aarch64-linux-gnu/deepdeps/libdeep.so.1" -x c - || { echo "libdeep did not build"; exit 1; }
+printf 'int deep_value(void);\nint mid_value(void) { return deep_value() + 1; }\n' |
+ aarch64-linux-gnu-gcc -shared -fPIC -w -Wl,-soname,libmid.so.1 -o "$work/root/usr/lib/mid/libmid.so.1" -x c - -x none \
+ "$work/root/usr/lib/aarch64-linux-gnu/deepdeps/libdeep.so.1" \
+ -Wl,--enable-new-dtags,-rpath,/usr/lib/aarch64-linux-gnu/deepdeps || { echo "libmid did not build"; exit 1; }
+printf 'int mid_value(void);\nint plugin_value(void) { return mid_value() + 1; }\n' |
+ aarch64-linux-gnu-gcc -shared -fPIC -w -o "$work/root/usr/lib/plugins/plugin2.so" -x c - -x none \
+ "$work/root/usr/lib/mid/libmid.so.1" || { echo "plugin2 did not build"; exit 1; }
+prog load_plugin2 <<'EOC'
+#include
+#include
+#include
+int main(int argc, char **argv) {
+ void *h = dlopen(argv[1], RTLD_NOW);
+ if (!h) {
+ const char *e = dlerror();
+ printf("dlopen failed: %s\n", e && strstr(e, "cannot open shared object file") ? "cannot open shared object file" : (e ? e : "no message"));
+ return 1;
+ }
+ int (*f)(void) = (int (*)(void))dlsym(h, argv[2]);
+ printf("%s %d\n", argv[2], f ? f() : -1);
+ return 0;
+}
+EOC
+run_mid() { # the directory to start in, then the library and the function
+ local dir=$1
+ shift
+ (cd "$dir" && env -i CORTEX_ROOT="$work/root" HOME="$work" timeout 60 qemu-aarch64 "$cross/ld-linux-aarch64.so.1" \
+ --preload "$work/hook.so" --library-path "$cross:$work/root/usr/lib/mid" "$work/load_plugin2" "$@" 2>&1 | grep -v "cannot be preloaded")
+}
+out="$(run_mid "$work" /usr/lib/plugins/plugin2.so plugin_value); $(run_mid "$work" libmid.so.1 mid_value)"
+out="$out; $(run_mid "$work/root/usr/lib" ./plugins/plugin2.so plugin_value); $(run_mid "$work" libnothere.so.9 x)"
+want="plugin_value 42; mid_value 41; plugin_value 42; dlopen failed: cannot open shared object file"
+check "dlopen() finds what a dependency has in its own RUNPATH: by path, by name, by a relative path" test "$out" = "$want"
+[ "$out" = "$want" ] || echo " got: $out"
+
# Credentials over a Unix socket (SCM_CREDENTIALS) go out as the process's real ones: getuid() is
-# root's 0 here, and the kernel refuses ids that are not the sender's (PulseAudio's clients failed)
+# root's 0 here, and the kernel refuses ids that are not the sender's (PulseAudio's clients failed).
+# What comes in (SCM_CREDENTIALS, SO_PEERCRED) is shown as the ids a program sees for itself: a
+# server that compares its peer with getuid() refused its own user (dbus-daemon, so XFCE's settings)
prog send_creds <<'EOC'
#define _GNU_SOURCE
#include
#include
#include
#include
+#include
int main(void) {
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) return 2;
int on = 1;
setsockopt(sv[1], SOL_SOCKET, SO_PASSCRED, &on, sizeof(on));
+ struct ucred peer = { 0, 12345, 12345 };
+ socklen_t peer_len = sizeof(peer);
+ if (getsockopt(sv[0], SOL_SOCKET, SO_PEERCRED, &peer, &peer_len) == 0)
+ printf("peer ids %u %u, mine %u %u\n", (unsigned)peer.uid, (unsigned)peer.gid, (unsigned)getuid(), (unsigned)getgid());
struct ucred cred = { getpid(), getuid(), getgid() };
char ctrl[CMSG_SPACE(sizeof(cred))];
char byte = 'x';
@@ -616,10 +696,130 @@ int main(void) {
}
EOC
out=$(run "$work/send_creds")
-# the ids the kernel knows (this shell's: the hook emulates root only inside); as root they are both
-# 0 and the rewrite cannot be told apart, CI runs the tests as a user
-check "SCM_CREDENTIALS are sent with the real ids" sh -c 'echo "$1" | grep -q "sendmsg sent" && echo "$1" | grep -q "received pid mine" && echo "$1" | grep -q "received ids $2 $3"' sh "$out" "$(id -u)" "$(id -g)"
-echo "$out" | grep -q "received pid mine" || echo "$out" | sed 's/^/ /'
+# The kernel takes only the sender's real ids (this shell's: the hook emulates root only inside), so
+# a message that went out had them; as root they are 0 as well and the rewrite cannot be told apart,
+# CI runs the tests as a user
+check "SCM_CREDENTIALS are sent with the real ids" sh -c 'echo "$1" | grep -q "sendmsg sent" && echo "$1" | grep -q "received pid mine"' sh "$out"
+check "the peer of a Unix socket has the ids the program sees for itself" sh -c 'echo "$1" | grep -q "peer ids 0 0, mine 0 0" && echo "$1" | grep -q "received ids 0 0"' sh "$out"
+echo "$out" | grep -q "received ids 0 0" || echo "$out" | sed 's/^/ /'
+
+# A library loaded with RTLD_DEEPBIND (every PHP extension) looks for libc's functions in libc
+# before the hook: its calls were not seen, so it opened Android's files instead of Ubuntu's
+echo marker > "$work/root/etc/deepbind-marker"
+printf '#include \nint plugin_sees(void) { return access("/etc/deepbind-marker", F_OK) == 0; }\n' |
+ aarch64-linux-gnu-gcc -shared -fPIC -w -o "$work/root/usr/lib/plugins/deepbind.so" -x c - || { echo "deepbind.so did not build"; exit 1; }
+prog load_deepbind <<'EOC'
+#define _GNU_SOURCE
+#include
+#include
+int main(void) {
+ void *h = dlopen("/usr/lib/plugins/deepbind.so", RTLD_NOW | RTLD_DEEPBIND);
+ if (!h) { printf("dlopen failed: %s\n", dlerror()); return 1; }
+ int (*f)(void) = (int (*)(void))dlsym(h, "plugin_sees");
+ printf("deepbind plugin %s\n", f && f() ? "sees Ubuntu's file" : "does not see Ubuntu's file");
+ return 0;
+}
+EOC
+out=$(run "$work/load_deepbind")
+check "a library loaded with RTLD_DEEPBIND still goes through the hook (PHP's extensions)" test "$out" = "deepbind plugin sees Ubuntu's file"
+[ "$out" = "deepbind plugin sees Ubuntu's file" ] || echo " got: $out"
+
+# Locks on a memfd: Android's SELinux policy refuses them (EACCES), and PHP keeps its opcache lock in
+# one ("php -S": Cannot create lock - Permission denied). CORTEX_MEMFD_LOCKS=deny makes the hook act
+# as on Android; the lock is then taken on a file of its own, which a forked child shares.
+prog memfd_lock <<'EOC'
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+static int try_lock(int fd) { // 1: taken, 0: someone else holds it
+ struct flock try = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, .l_len = 1 };
+ return fcntl(fd, F_SETLK, &try) == 0;
+}
+int main(void) {
+ int fd = memfd_create("opcache_lock", MFD_CLOEXEC);
+ if (fd < 0) { perror("memfd_create"); return 2; }
+ struct flock lock = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, .l_len = 1 };
+ int rc = fcntl(fd, F_SETLKW, &lock);
+ printf("lock %d %s\n", rc, rc ? strerror(errno) : "taken");
+ fflush(stdout);
+ pid_t pid = fork();
+ if (pid == 0) {
+ int held = !try_lock(fd);
+ struct flock who = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, .l_len = 1 };
+ int asked = fcntl(fd, F_GETLK, &who);
+ printf("child: %s, holder %s\n", held ? "refused" : "got it too", asked == 0 && who.l_type == F_WRLCK && who.l_pid == getppid() ? "is the parent" : "unknown");
+ fflush(stdout);
+ _exit(0);
+ }
+ int status = 0;
+ waitpid(pid, &status, 0);
+ lock.l_type = F_UNLCK;
+ rc = fcntl(fd, F_SETLK, &lock);
+ printf("unlock %d\n", rc);
+
+ // forked before either of them locked it: both still meet on the same lock
+ int early = memfd_create("early", MFD_CLOEXEC);
+ int to_child[2], to_parent[2];
+ if (pipe(to_child) != 0 || pipe(to_parent) != 0) return 3;
+ fflush(stdout);
+ pid = fork();
+ if (pid == 0) {
+ char c;
+ int mine = try_lock(early);
+ if (write(to_parent[1], "x", 1) != 1 || read(to_child[0], &c, 1) != 1) _exit(1);
+ _exit(mine ? 0 : 7);
+ }
+ char c;
+ if (read(to_parent[0], &c, 1) != 1) return 4;
+ int parent_got = try_lock(early); // the child holds it now
+ // through a second descriptor of the memfd it is the same lock
+ int alias = dup(early);
+ int alias_got = try_lock(alias);
+ if (write(to_child[1], "x", 1) != 1) return 5;
+ waitpid(pid, &status, 0);
+ printf("early fork: child %s, parent %s, alias %s\n", WIFEXITED(status) && WEXITSTATUS(status) == 0 ? "locked" : "failed",
+ parent_got ? "got it too" : "refused", alias_got ? "got it too" : "refused");
+
+ // closing the memfd gives its locks up, as closing a file does
+ int closing = memfd_create("closing", MFD_CLOEXEC);
+ int copy = dup(closing);
+ rc = try_lock(closing);
+ close(copy);
+ fflush(stdout);
+ pid = fork();
+ if (pid == 0) _exit(try_lock(closing) ? 0 : 7);
+ waitpid(pid, &status, 0);
+ printf("after close: locked %d, child %s\n", rc, WIFEXITED(status) && WEXITSTATUS(status) == 0 ? "locked" : "refused");
+
+ int other = memfd_create("second", MFD_CLOEXEC);
+ rc = flock(other, LOCK_EX | LOCK_NB);
+ int rc2 = flock(other, LOCK_UN);
+ printf("flock %d %d\n", rc, rc2);
+ // a file that is not a memfd is not touched, and a refusal of another kind stays one
+ rc = fcntl(-1, F_SETLK, &lock);
+ int bad = errno;
+ printf("bad fd %d %s\n", rc, bad == EBADF ? "EBADF" : "other");
+ return 0;
+}
+EOC
+mkdir -p "$work/root/tmp"
+out=$( (cd "$work" && env -i CORTEX_ROOT="$work/root" HOME="$work" CORTEX_MEMFD_LOCKS=deny timeout 60 qemu-aarch64 "$cross/ld-linux-aarch64.so.1" \
+ --preload "$work/hook.so" --library-path "$cross" "$work/memfd_lock" 2>&1 | grep -v "cannot be preloaded") )
+want="lock 0 taken
+child: refused, holder is the parent
+unlock 0
+early fork: child locked, parent refused, alias refused
+after close: locked 1, child locked
+flock 0 0
+bad fd -1 EBADF"
+check "a memfd can be locked where Android refuses it: children share the lock, closing gives it up" test "$out" = "$want"
+[ "$out" = "$want" ] || echo "$out" | sed 's/^/ /'
echo
echo "$passed passed, $failed failed"
diff --git a/tests/scripts/test_shell_scripts.sh b/tests/scripts/test_shell_scripts.sh
index 69ef17a..fcb094d 100644
--- a/tests/scripts/test_shell_scripts.sh
+++ b/tests/scripts/test_shell_scripts.sh
@@ -925,6 +925,78 @@ else
fail "cortex-desktop start --size without a value"
fi
desktop_ stop >/dev/null
+# The databases GTK needs (file types, the programs that open them) are built by the real programs
+# behind Cortex's stand-ins when they are missing or a package added to them, and only then
+printf '#!/bin/bash\necho "$*" > "$CORTEX_DESKTOP_ROOT/tmp/session-args"\necho "NO_AT_BRIDGE=${NO_AT_BRIDGE:-}" > "$CORTEX_DESKTOP_ROOT/tmp/session-env"\nexec -a dbus-launch sleep 300\n' > "$DBIN/dbus-launch"
+mkdir -p "$DR/usr/bin" "$DR/usr/share/mime/packages" "$DR/usr/share/applications"
+echo "" > "$DR/usr/share/mime/packages/freedesktop.org.xml"
+echo "[Desktop Entry]" > "$DR/usr/share/applications/thunar.desktop"
+printf '#!/bin/sh\nexit 0\n' > "$DR/usr/bin/update-mime-database"
+printf '#!/bin/sh\necho run >> "$1/../../../tmp/mime-runs"\necho cache > "$1/mime.cache"\n' > "$DR/usr/bin/update-mime-database.distrib"
+printf '#!/bin/sh\necho run >> "$1/../../../tmp/desktop-runs"\necho cache > "$1/mimeinfo.cache"\n' > "$DR/usr/bin/update-desktop-database"
+chmod +x "$DR/usr/bin/update-mime-database" "$DR/usr/bin/update-mime-database.distrib" "$DR/usr/bin/update-desktop-database"
+echo "DESKTOP=xfce" > "$DR/etc/cortex/desktop.conf"
+desktop_ start >/dev/null 2>&1
+sleep 0.5
+if [ -f "$DR/usr/share/mime/mime.cache" ] && [ -f "$DR/usr/share/applications/mimeinfo.cache" ] \
+ && [ "$(wc -l < "$DR/tmp/mime-runs")" = 1 ] && [ "$(wc -l < "$DR/tmp/desktop-runs")" = 1 ]; then
+ pass "cortex-desktop start builds the file type and program databases with the real programs"
+else
+ fail "cortex-desktop databases: $(ls "$DR/usr/share/mime" "$DR/usr/share/applications" 2>&1 | tr '\n' ' ')"
+fi
+XFWM="$TEST_TMP/desktop_home/.config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml"
+if grep -q "exit-with-session startxfce4" "$DR/tmp/session-args" 2>/dev/null \
+ && grep -q 'name="use_compositing" type="bool" value="false"' "$XFWM" 2>/dev/null \
+ && grep -q "NO_AT_BRIDGE=1" "$DR/tmp/session-env" 2>/dev/null; then
+ pass "cortex-desktop starts XFCE without the compositor and the accessibility bus"
+else
+ fail "cortex-desktop XFCE session: $(cat "$DR/tmp/session-args" "$DR/tmp/session-env" 2>/dev/null)"
+fi
+desktop_ stop >/dev/null
+echo "mine" > "$XFWM"
+desktop_ start >/dev/null 2>&1
+desktop_ stop >/dev/null
+RUNS_SAME="$(wc -l < "$DR/tmp/mime-runs")"
+sleep 1.1
+echo "" > "$DR/usr/share/mime/packages/gimp.xml"
+desktop_ start >/dev/null 2>&1
+if [ "$RUNS_SAME" = 1 ] && [ "$(wc -l < "$DR/tmp/mime-runs")" = 2 ] && [ "$(wc -l < "$DR/tmp/desktop-runs")" = 3 ] \
+ && [ "$(cat "$XFWM")" = "mine" ]; then
+ pass "the file types are read again only after a package added to them, the user's XFCE settings stay"
+else
+ fail "cortex-desktop database refresh: $RUNS_SAME then $(wc -l < "$DR/tmp/mime-runs") mime runs, $(wc -l < "$DR/tmp/desktop-runs") desktop runs, xfwm4.xml: $(cat "$XFWM")"
+fi
+desktop_ stop >/dev/null
+# Sound: one PulseAudio of the desktop's own, known by its pid and its socket ("pulseaudio --kill"
+# finds none in Cortex), ended with the desktop; one left behind by an older version goes as well
+cat > "$DBIN/pulseaudio" <<'EOS'
+#!/bin/bash
+sock=""
+for a in "$@"; do case "$a" in *socket=*) sock="${a#*socket=}"; sock="${sock%% *}" ;; esac; done
+exec -a pulseaudio python3 -c 'import socket, sys, time
+s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(1); time.sleep(300)' "$sock" "$@"
+EOS
+(exec -a pulseaudio python3 -c 'import time; time.sleep(300)' -L "module-native-protocol-unix socket=$DR/tmp/.cortex-desktop/pulse.sock auth-anonymous=1") &
+STRAY_PID=$!
+desktop_ start >/dev/null 2>&1
+PULSE_PID="$(cat "$DR/tmp/.cortex-desktop/pulse.pid" 2>/dev/null)"
+sleep 0.3
+if kill -0 "$PULSE_PID" 2>/dev/null && [ "$(cat "$DR/tmp/.cortex-desktop/pulse.server")" = "unix:$DR/tmp/.cortex-desktop/pulse.sock" ] \
+ && { ! kill -0 "$STRAY_PID" 2>/dev/null || grep -q '^State:[[:space:]]*Z' "/proc/$STRAY_PID/status"; }; then
+ pass "cortex-desktop starts one PulseAudio of its own and ends one left behind"
+else
+ fail "cortex-desktop sound: pid '$PULSE_PID', server '$(cat "$DR/tmp/.cortex-desktop/pulse.server" 2>/dev/null)'"
+ kill "$STRAY_PID" 2>/dev/null
+fi
+desktop_ stop >/dev/null
+sleep 0.3
+if { ! kill -0 "$PULSE_PID" 2>/dev/null || grep -q '^State:[[:space:]]*Z' "/proc/$PULSE_PID/status"; } \
+ && [ ! -e "$DR/tmp/.cortex-desktop/pulse.sock" ] && [ ! -e "$DR/tmp/.cortex-desktop/pulse.server" ]; then
+ pass "cortex-desktop stop ends its PulseAudio"
+else
+ fail "cortex-desktop stop left PulseAudio $PULSE_PID running"
+ kill "$PULSE_PID" 2>/dev/null
+fi
pkill -f "$DR/tmp/.cortex-desktop/vnc.sock" 2>/dev/null
echo "=== 7. Checking translations ==="