diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 5a10d95..ac4391f 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -131,8 +131,8 @@ jobs: 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 - aarch64-linux-gnu-gcc -std=gnu11 -shared -fPIC -O3 -Wall -Wno-nonnull-compare -Wl,-z,max-page-size=16384 -o hook-libs/libcortex-hook-arm64.so app/src/main/cpp/cortex-hook.c -ldl - arm-linux-gnueabihf-gcc -std=gnu11 -shared -fPIC -O3 -Wall -Wno-nonnull-compare -Wl,-z,max-page-size=16384 -o hook-libs/libcortex-hook-arm.so app/src/main/cpp/cortex-hook.c -ldl + aarch64-linux-gnu-gcc -std=gnu11 -shared -fPIC -O3 -fno-delete-null-pointer-checks -Wall -Wno-nonnull-compare -Wl,-z,max-page-size=16384 -o hook-libs/libcortex-hook-arm64.so app/src/main/cpp/cortex-hook.c -ldl + arm-linux-gnueabihf-gcc -std=gnu11 -shared -fPIC -O3 -fno-delete-null-pointer-checks -Wall -Wno-nonnull-compare -Wl,-z,max-page-size=16384 -o hook-libs/libcortex-hook-arm.so app/src/main/cpp/cortex-hook.c -ldl # Root mode's helper: a static executable, run as root before any chroot exists aarch64-linux-gnu-gcc -std=gnu11 -static -O2 -Wall -Wextra -Werror -Wl,-z,max-page-size=16384 -o hook-libs/cortex-chroot-arm64.elf app/src/main/cpp/root/cortex-chroot.c arm-linux-gnueabihf-gcc -std=gnu11 -static -O2 -Wall -Wextra -Werror -Wl,-z,max-page-size=16384 -o hook-libs/cortex-chroot-arm.elf app/src/main/cpp/root/cortex-chroot.c @@ -341,7 +341,8 @@ jobs: # Static Go programs (hook/go_programs.c, gosys/): built for AArch64, patched like the hook does # and run with qemu-user on a root file system of Ubuntu's own arm64 packages. It also makes - # the added code's header, which the native tests below compile the hook with. + # the added code's header, which the native tests below compile the hook with. The hook's own + # programs under qemu-user (test_hook_qemu.sh) run here too. - name: Test static Go programs if: github.event_name == 'pull_request' || github.event.inputs.dry_run == 'true' env: @@ -351,6 +352,7 @@ jobs: 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 bash tests/scripts/test_go_programs.sh + CORTEX_REQUIRE_QEMU_TESTS=1 bash tests/scripts/test_hook_qemu.sh - name: Run Native & Shell Script Tests and Verify PR Debug Build if: github.event_name == 'pull_request' diff --git a/.github/workflows/compat-nightly.yml b/.github/workflows/compat-nightly.yml index 6a5bbd1..2f430f5 100644 --- a/.github/workflows/compat-nightly.yml +++ b/.github/workflows/compat-nightly.yml @@ -13,9 +13,18 @@ on: description: "The release to test (its tag; empty: the latest)" type: string default: "" + only: + description: "Only these packages (names of the table, separated by spaces; empty: all)" + type: string + default: "" + apk_run: + description: "Test the APKs of this run of Build and Release (a dry run's artifact) instead of a release" + type: string + default: "" permissions: contents: read + actions: read concurrency: group: package-compatibility @@ -34,12 +43,20 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.release }} + APK_RUN: ${{ inputs.apk_run }} run: | set -euo pipefail mkdir -p apks - # Without a tag, gh takes the latest release ($TAG is left unquoted for that) - gh release download $TAG --repo "$GITHUB_REPOSITORY" --pattern 'Cortex-arm64-v8a-signed.apk' --dir apks - gh release view $TAG --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName > tag.txt + if [ -n "$APK_RUN" ]; then + # The APKs a run of Build and Release made (a dry run keeps them for a day) + gh run download "$APK_RUN" --repo "$GITHUB_REPOSITORY" --name cortex-release-apks --dir apk-run + cp "$(find apk-run -name 'Cortex-arm64-v8a-signed.apk' | head -n 1)" apks/ + echo "run $APK_RUN" > tag.txt + else + # Without a tag, gh takes the latest release ($TAG is left unquoted for that) + gh release download $TAG --repo "$GITHUB_REPOSITORY" --pattern 'Cortex-arm64-v8a-signed.apk' --dir apks + gh release view $TAG --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName > tag.txt + fi echo "Testing $(cat tag.txt)" ls -l apks @@ -51,6 +68,8 @@ jobs: - name: Install and try the packages on the emulator uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + env: + E2E_COMPAT_ONLY: ${{ inputs.only }} with: api-level: 34 arch: x86_64 diff --git a/CHANGELOG.md b/CHANGELOG.md index d8512bf..7d4d5c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,85 @@ 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.26.40 (2026-10-25) + +### Fixed + +- **redis, nginx and PostgreSQL install.** The Ubuntu image kept only `root`, `daemon` and `cortex` in + `/etc/passwd` and `/etc/group`, but the scripts of packages use the system users and groups every + Ubuntu has (`man`, `www-data`, `adm`, `tty`, `staff`...): `apt install redis-server`, `nginx` or + `postgresql` (and any package whose man page the man-db trigger makes, with `setpriv --reuid=man`) + stopped halfway. Cortex now adds what is missing from the list Ubuntu's own `base-passwd` keeps in + the image (`/usr/share/base-passwd`), with locked entries in `/etc/shadow` and `/etc/gshadow`, and + changes nothing that is there. redis then starts and answers (`PONG`) in the emulator test, nginx + installs and serves a page. PostgreSQL installs too, but its server refuses to run as root and an + app cannot become another user, so the cluster the package would make is not made outside root + mode (`psql` works). MariaDB (`mariadb-server`) installs, starts and answers queries. +- **ripgrep, `rustc` and `cargo` crashed at the first file they opened.** Rust asks whether `statx` + exists by calling it with a NULL path; glibc's header says the path is never NULL, so the compiler + deleted the hook's check. Both programs now work (emulator test: ripgrep and Rust's hello world). +- **`chfn` and `adduser --gecos` (which PostgreSQL's installation runs) failed** ("Authentication + failure"): Ubuntu's libraries found Android's SELinux and PAM's root check then refused root. + SELinux is off for Ubuntu's programs, and the shadow database is read from the image's + `/etc/shadow` (`getspnam`). +- **`go run` and `go build` could crash at random** (a segmentation fault in the Go tool): the + scratch memory that Cortex's change in a Go program uses for the paths it rewrites was shared by + goroutines that ran at the same time. Each slot now belongs to the goroutine that took it until + it is done (the build runs a stress test: 96 goroutines read 200 files 300 times each). And the + slot of a program that was started (by a vfork child, whose execve does not return) is given back + when the goroutine goes on; before, a program that started programs from more than 64 goroutines + at once could wait for a slot for good. +- **`sudo` and `su` on a phone without root said "root not found".** `sudo apt install x`, the first + line of nearly every guide, now runs the command as it is (Cortex's shell is root inside Ubuntu as + far as the programs can tell); `sudo -u USER`, `sudo -i`, `su USER` (in the shell that user has), + `su -c CMD` and `su -` work through `runuser` and the shell. On a phone that gives Cortex real root they still ask Android for it. +- **After another color scheme was chosen, the old one came back as a block** (#29): close the + keyboard and the rows the terminal grew into showed the previous scheme's background, in the size + of the keyboard. The cells of a session keep the colors the scheme had when they were written, and + a change of scheme only changed what was drawn next. Cortex now carries the new scheme into the + screen, the scrollback and the other screen of every session when it is chosen (the default + colors and the 16 ANSI ones; the 256-color and 24-bit colors of programs are the same in every + scheme). +- A program that called `putenv()` or `setenv()` and then `execv()` before it used any file could + crash in the exec (the hook set its variables only then, and the block the program had passed was + freed by that). The hook now starts before the program does. + +### Faster + +- **Starting programs.** What the library adds to starting a program is about half of what it was. + The look for the ARM translator's memory files (x86 emulators and Chromebooks; a phone has none) + asked about each descriptor from 3 to 63, 61 system calls in every program; it now reads + `/proc/self/fd` once, and only in the first program of a session: the ones it starts are told + there is nothing to find (`CORTEX_NT=0`). `ld.so` is given every library directory once instead + of twice, and the environment is prepared with a fifth of the comparisons. Measured against 1.26.39 + with the same benchmark on one x86 machine: the library's share of starting `ld.so` 457 µs -> + 189 µs, starting a program as a shell does 16% faster, a script 11%. Counted with qemu-user a + program that does nothing makes 177 system calls instead of 236 (170 without the library), a + shell starting a program five times 548 instead of 916, a login shell 1,111 instead of 1,357, + `dpkg -l` 569 instead of 752, `ls -l` 713 instead of 837. +- **Less waking of the phone.** The watch for local web servers looked every 3 seconds even when + nothing was listening, and on Android 10 and newer every look asked `/proc/net`, which they + refuse (each refusal a line in the system log). There it now looks every 15 seconds when there is + nothing to follow and asks `/proc/net` again only every 10 minutes; the hook's own reports still + wake it at once, and a server that does not answer yet is looked at again within 3 seconds. +- No warning from the compiler or the Android SDK that the code can avoid: the unused function and + variable, `stopForeground(true)` and `scaledDensity` are replaced. + +### For developers + +- `tests/scripts/test_hook_qemu.sh` runs the hook under qemu-user with small programs (statx with a + NULL path, SELinux, the shadow file, the translator's descriptors, `CORTEX_NT`, `putenv` + + `execv`); the build runs it. `tests/scripts/test_go_programs.sh` has a stress test of the Go + change. `tests/scripts/test_shell_scripts.sh` covers `sudo` and `su` without root. +- The package compatibility run takes the packages to test (`only`; a name that is not in the table, + or a list that is not names, fails the run instead of testing nothing or everything) and the APKs + of a dry run (`apk_run`); the nginx test installs the `curl` it needs, and PostgreSQL has a client row (passes) + next to the server row (a known gap outside root mode: the server refuses to run as root, and an + app cannot become another user). +- Known gaps, seen in the x86 emulator only: Go programs and the JVM's compiler crash inside the + emulator's ARM translator (a Go program also when it is not changed by Cortex; they run under + qemu-user and on phones). + ## 1.26.39 (2026-10-25) ### New diff --git a/README.md b/README.md index 272f677..2dd59ec 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ Measured on one 8-core Android phone (Linux 4.14, one big core, median of 9 inte **Starting a program** (what a shell does for every command): the library made 19 system calls before the program started and now makes 7, no longer changes the permissions of `ld.so` each time, and lists the library directories that hold nearly every library first, so a program no longer pays two failed lookups per library (`apt-cache` made 34 of them, `bash` 4, now none). Starting a program through the library takes 769 µs instead of 903 µs; starting `ld.so` by hand without the library takes 491 µs. +**Starting a program in 1.26.40**: measured against the previous release on one x86 Linux machine with the same benchmark (`tests/bench/run-micro.sh`, 25 interleaved runs each): what the library adds to starting `ld.so` with a program fell from 457 µs to 189 µs, and starting a program the way a shell does is 16% faster (1,253 µs to 1,053 µs; a script 11%). The library no longer looks into `/proc/self/fd` in every program (the first program of a session looks; those it starts are told by `CORTEX_NT=0` that there is nothing to find, as on any phone that runs ARM code itself), no longer repeats the library directories in the `--library-path` it gives `ld.so` (which went through each of them twice), and compares only the names that begin with the same letter when it prepares the environment (this was 81,000 instructions before every start, now 17,000). Counted with qemu-user, a program that does nothing makes 177 system calls instead of 236 (170 without the library), a shell that starts a program five times 548 instead of 916, a login shell 1,111 instead of 1,357, `dpkg -l` 569 instead of 752. These figures come from an x86 machine and from qemu-user; a phone has not been measured, and shows other times. + **Whole commands** (`find`, `ls -lR`, `dpkg`, `tar`, `grep -r`, compiling a C file, reinstalling 36 packages) differ from the previous version by less than the up to ±10% variation of the phone from run to run, with two exceptions that held in two separately designed measurements: `ls -lR` is about 20% faster (it no longer prints an error line for every symbolic link) and a shell that starts a program a hundred times is 5 to 14% faster. The library was a small part of the other commands to begin with. Numbers differ from phone to phone, so compare builds on the same device. To measure your own device (inside Cortex, with a C compiler installed): @@ -286,7 +288,10 @@ Use `apt` like on any Ubuntu system, for example `apt install git python3 build- Not yet. Cortex is a terminal; there is no X11 or Wayland server. **Will every Ubuntu package work?** -Most command-line tools do. Packages that depend on kernel features Android does not allow for apps (for example systemd services or privileged device access) cannot work. On a rooted phone, [root mode](#root-mode) removes the limits that come from running as an app; systemd cannot be the init system in either mode. +Most command-line tools do. Packages that depend on kernel features Android does not allow for apps (for example systemd services or privileged device access) cannot work. An app cannot become another user, so a server that refuses to run as root cannot run in normal mode: PostgreSQL's packages install (`psql` works against a server elsewhere), but its own cluster is not created and the server not started; use [root mode](#root-mode) for that. MariaDB (`apt install mariadb-server`, then `mariadbd-safe &` and `mariadb`) and redis run in normal mode. On a rooted phone, [root mode](#root-mode) removes the limits that come from running as an app; systemd cannot be the init system in either mode. + +**Does `sudo` work?** +Yes. In normal mode Cortex's shell is root as far as Ubuntu's programs can tell, so `sudo apt install x` and the other commands from guides run the command as it is (`sudo -u USER` and `su USER` go through `runuser`). On a rooted phone that gives Cortex root, `sudo` and `su` still ask Android for real root, as before; in [root mode](#root-mode) they work as on a Linux PC. **Can I compile C and C++ programs?** Yes. `apt install build-essential` installs gcc, g++ and make, and the C library headers and libraries (`libc6-dev`, `linux-libc-dev`) come with the app, so `-dev` packages such as `zlib1g-dev` or `python3-dev` install too. Statically linked programs (`gcc -static`) run as well: before such a program starts for the first time, Cortex patches the system calls that Android's seccomp filter forbids for apps in its file (once; the file keeps its times and mode). One limit: Cortex keeps the C library itself (`libc6`, `libc-bin`) at the version it ships, because Ubuntu's own build does not start under Android's filter, so `apt upgrade` leaves those packages alone. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbcb425..9588e27 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 = 12639 - versionName = "1.26.39" + versionCode = 12640 + versionName = "1.26.40" // UbuntuOnDeviceTest (app/src/androidTest), on Firebase Test Lab testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/assets/scripts/su.sh b/app/src/main/assets/scripts/su.sh index 2164378..98a252c 100644 --- a/app/src/main/assets/scripts/su.sh +++ b/app/src/main/assets/scripts/su.sh @@ -19,41 +19,96 @@ quote_args() { INVOKED_AS="${CORTEX_INVOKED_AS:-$(basename "$0")}" unset CORTEX_INVOKED_AS +# sudo (and "root") where the shell already is root, as far as the programs can tell: real root in +# root mode, and in normal mode Cortex's own root of the Ubuntu files (the preloaded library presents +# the app's user as root). The command is run as it is; "-u USER" goes through runuser. +sudo_direct() { + local target_user="" login=0 + while [ "$#" -gt 0 ]; do + case "$1" in + -u|--user) target_user="${2:-}"; shift; [ "$#" -gt 0 ] && shift ;; + -u*) target_user="${1#-u}"; shift ;; + --user=*) target_user="${1#--user=}"; shift ;; + -i|--login) login=1; shift ;; + -g|--group|-p|--prompt|-C|--close-from|-D|--chdir|-h|--host|-r|--role|-t|--type|-T|--command-timeout) + shift; [ "$#" -gt 0 ] && shift ;; + --) shift; break ;; + -*) shift ;; # -E, -H, -n, -S, -k, -s, ...: nothing to do as root + *) break ;; + esac + done + if [ -n "$target_user" ] && [ "$target_user" != "root" ] && [ "$target_user" != "0" ]; then + if [ "$login" = 1 ]; then + [ "$#" -eq 0 ] && exec runuser -l "$target_user" + exec runuser -l "$target_user" -c "$(quote_args "$@")" + fi + [ "$#" -eq 0 ] && exec runuser -u "$target_user" -- "${SHELL:-/bin/bash}" + exec runuser -u "$target_user" -- "$@" + fi + if [ "$#" -eq 0 ]; then + [ "$login" = 1 ] && exec /bin/bash -l + exec "${SHELL:-/bin/bash}" + fi + [ "$login" = 1 ] && exec /bin/bash -l -c "$(quote_args "$@")" + exec "$@" +} + +# su (and tsu) on a phone without root: root's shell is the one already running; another user goes +# through runuser, which sets the ids the way the preloaded library lets it. Takes the usual forms: +# su, su -, su -c CMD, su USER, su - USER, su USER -c CMD, su -s SHELL. +su_direct() { + local login=0 command="" have_command=0 target_user="" shell_path="" + local shell_args=() + # Android's su starts with -c and hands what follows the command to the shell ("su -c CMD ARG..."), + # where Ubuntu's su takes the user name there + local android_style=0 + [ "${1:-}" = "-c" ] && android_style=1 + while [ "$#" -gt 0 ]; do + case "$1" in + -|-l|--login) login=1; shift ;; + -c|--command) have_command=1; command="${2:-}"; shift; [ "$#" -gt 0 ] && shift ;; + --command=*) have_command=1; command="${1#--command=}"; shift ;; + -s|--shell) shell_path="${2:-}"; shift; [ "$#" -gt 0 ] && shift ;; + --shell=*) shell_path="${1#--shell=}"; shift ;; + -g|--group|-G|--supp-group|-w|--whitelist-environment) shift; [ "$#" -gt 0 ] && shift ;; + --) shift; break ;; + -*) shift ;; # -m, -p, -P, --preserve-environment, ... + *) + if [ -z "$target_user" ] && [ "$android_style" = 0 ]; then target_user="$1"; else shell_args+=("$1"); fi + shift ;; + esac + done + shell_args+=("$@") + case "$target_user" in root|0) target_user="" ;; esac + # As Ubuntu's su: another user's shell is the one that user has, unless -s names one + local shell_cmd="$shell_path" + if [ -z "$shell_cmd" ]; then + if [ -n "$target_user" ]; then + shell_cmd="$(getent passwd "$target_user" 2>/dev/null | cut -d: -f7)" + [ -n "$shell_cmd" ] || shell_cmd=/bin/sh + else + shell_cmd="${SHELL:-/bin/bash}" + fi + fi + if [ -n "$target_user" ]; then + if [ "$login" = 1 ]; then + [ "$have_command" = 1 ] && exec runuser -l "$target_user" -c "$command" + exec runuser -l "$target_user" + fi + [ "$have_command" = 1 ] && exec runuser -u "$target_user" -- "$shell_cmd" -c "$command" "${shell_args[@]}" + exec runuser -u "$target_user" -- "$shell_cmd" "${shell_args[@]}" + fi + local login_flag=() + [ "$login" = 1 ] && login_flag=(-l) + [ "$have_command" = 1 ] && exec "$shell_cmd" "${login_flag[@]}" -c "$command" "${shell_args[@]}" + exec "$shell_cmd" "${login_flag[@]}" "${shell_args[@]}" +} + # Root mode: the shell is already real root in a chroot of the rootfs (normal mode always has # CORTEX_ROOT and sees Android's /system). su is Ubuntu's own su then, and sudo runs the command. if [ -n "${CORTEX_CHROOT:-}" ] || { [ -z "${CORTEX_ROOT:-}" ] && [ ! -d /system/bin ]; }; then case "$INVOKED_AS" in - sudo|root) - target_user="" - login=0 - while [ "$#" -gt 0 ]; do - case "$1" in - -u|--user) target_user="${2:-}"; shift; [ "$#" -gt 0 ] && shift ;; - -u*) target_user="${1#-u}"; shift ;; - --user=*) target_user="${1#--user=}"; shift ;; - -i|--login) login=1; shift ;; - -g|--group|-p|--prompt|-C|--close-from|-D|--chdir|-h|--host|-r|--role|-t|--type|-T|--command-timeout) - shift; [ "$#" -gt 0 ] && shift ;; - --) shift; break ;; - -*) shift ;; # -E, -H, -n, -S, -k, -s, ...: nothing to do as root - *) break ;; - esac - done - if [ -n "$target_user" ] && [ "$target_user" != "root" ] && [ "$target_user" != "0" ]; then - if [ "$login" = 1 ]; then - [ "$#" -eq 0 ] && exec runuser -l "$target_user" - exec runuser -l "$target_user" -c "$(quote_args "$@")" - fi - [ "$#" -eq 0 ] && exec runuser -u "$target_user" -- "${SHELL:-/bin/bash}" - exec runuser -u "$target_user" -- "$@" - fi - if [ "$#" -eq 0 ]; then - [ "$login" = 1 ] && exec /bin/bash -l - exec "${SHELL:-/bin/bash}" - fi - [ "$login" = 1 ] && exec /bin/bash -l -c "$(quote_args "$@")" - exec "$@" - ;; + sudo|root) sudo_direct "$@" ;; *) for real_su in ${CORTEX_REAL_SU:-} /usr/bin/su /bin/su; do if [ -x "$real_su" ]; then @@ -65,6 +120,15 @@ if [ -n "${CORTEX_CHROOT:-}" ] || { [ -z "${CORTEX_ROOT:-}" ] && [ ! -d /system/ esac fi +# No Android root to ask (nothing called su on the phone, or it does not give root): Cortex's root +# of the Ubuntu files is what there is, and "sudo apt install x" from any guide works as it is. +no_android_root() { + case "$INVOKED_AS" in + sudo|root) sudo_direct "$@" ;; + *) su_direct "$@" ;; + esac +} + find_host_su() { if [ -n "${CORTEX_HOST_SU:-}" ]; then echo "$CORTEX_HOST_SU" @@ -122,9 +186,9 @@ find_host_su() { HOST_SU=$(find_host_su) -if [ -z "$HOST_SU" ]; then - echo "root not found" >&2 - exit 1 +# find_host_su answers plain "su" when it found nothing +if [ -z "$HOST_SU" ] || [ "$HOST_SU" = "su" ]; then + no_android_root "$@" fi if [ -x "$HOST_SU" ] || [ -f "$HOST_SU" ]; then @@ -168,8 +232,7 @@ CHECK_ROOT() { } if ! CHECK_ROOT; then - echo "root not found" >&2 - exit 1 + no_android_root "$@" fi if [ -z "${CORTEX_ROOT:-}" ]; then diff --git a/app/src/main/cpp/cortex-hook.c b/app/src/main/cpp/cortex-hook.c index 2997d2d..fccf72c 100644 --- a/app/src/main/cpp/cortex-hook.c +++ b/app/src/main/cpp/cortex-hook.c @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include diff --git a/app/src/main/cpp/gosys/gosys.c b/app/src/main/cpp/gosys/gosys.c index 5e8ff17..9ff70bb 100644 --- a/app/src/main/cpp/gosys/gosys.c +++ b/app/src/main/cpp/gosys/gosys.c @@ -166,10 +166,81 @@ static int rewriting(const struct gosys_hdr *h) { return m == 1; } -static char *take_slot(const struct gosys_hdr *h) { +#define NO_SLOT 0xffffffffu +#define NR_SCHED_YIELD 124 + +// Who is making the call: the goroutine (x28 holds it, and the code is built not to touch x28). A +// goroutine makes one call at a time, and the vfork child that a goroutine makes runs with the same +// x28 as the goroutine does. (TPIDR_EL0 does not tell the threads of a Go program apart: its runtime +// leaves it unset without cgo.) +static inline u64 thread_tag(void) { + u64 t; + __asm__ volatile("mov %0, x28" : "=r"(t)); + return t; +} + +// How many execve calls have not returned (state area, offset 8). One that works never does: it is +// made by a vfork child, and the slot its path was in stays marked as the goroutine's, which goes on +// when the child is gone (the same x28, a call of its own). Without this, goroutines that each +// started a program held a slot each for good, and after the sixty-fourth all the others waited. +static inline u32 *execs_on_their_way(const struct gosys_hdr *h) { + return (u32 *)((char *)h + h->scratch_rel + 8); +} + +static void count_down(u32 *count) { + u32 n = __atomic_load_n(count, __ATOMIC_RELAXED); + while (n != 0 && !__atomic_compare_exchange_n(count, &n, n - 1, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) { + } +} + +// Gives back what this goroutine's vfork child left (see above). Every call looks at one counter +// for it; the list of slots is only looked through while an execve is on its way. +static void reclaim_left_slots(const struct gosys_hdr *h) { + u32 *count = execs_on_their_way(h); + if (__atomic_load_n(count, __ATOMIC_RELAXED) == 0) return; + u64 *owner = (u64 *)((char *)h + h->scratch_rel + 64); + u64 me = thread_tag() | 1; + for (u32 i = 0; i < GOSYS_SLOTS; i++) { + if (__atomic_load_n(&owner[i], __ATOMIC_RELAXED) != me) continue; + __atomic_store_n(&owner[i], 0, __ATOMIC_RELEASE); + count_down(count); + } +} + +// A slot for the call this goroutine is making; *held is its number, to give back when the call is +// done. Each slot has a word of its own that holds who has it (0: nobody), so that taking one is one +// compare-and-swap, and no other goroutine can ever mistake it for its own. +static char *take_slot(const struct gosys_hdr *h, u32 *held) { + char *base = (char *)h + h->scratch_rel; + u64 *owner = (u64 *)(base + 64); + u64 me = thread_tag() | 1; // never 0 + u32 mine = NO_SLOT; + // A slot of this goroutine that is still taken now (cxgo_entry gives back those of its vfork + // children) is its own again + for (u32 i = 0; i < GOSYS_SLOTS; i++) { + if (__atomic_load_n(&owner[i], __ATOMIC_RELAXED) != me) continue; + if (mine == NO_SLOT) + mine = i; + else + __atomic_store_n(&owner[i], 0, __ATOMIC_RELEASE); + } + while (mine == NO_SLOT) { + for (u32 i = 0; i < GOSYS_SLOTS && mine == NO_SLOT; i++) { + u64 none = 0; + if (__atomic_load_n(&owner[i], __ATOMIC_RELAXED) == 0 && + __atomic_compare_exchange_n(&owner[i], &none, me, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) + mine = i; + } + if (mine == NO_SLOT) sc(NR_SCHED_YIELD, 0, 0, 0, 0, 0, 0); // all taken: the others finish soon + } + *held = mine; + return base + GOSYS_STATE_SIZE + (u64)mine * GOSYS_SLOT_SIZE; +} + +static void put_slot(const struct gosys_hdr *h, u32 held) { + if (held == NO_SLOT) return; char *base = (char *)h + h->scratch_rel; - u32 n = __atomic_fetch_add((u32 *)base, 1u, __ATOMIC_RELAXED); - return base + GOSYS_STATE_SIZE + (u64)(n % GOSYS_SLOTS) * GOSYS_SLOT_SIZE; + __atomic_store_n((u64 *)(base + 64) + held, 0, __ATOMIC_RELEASE); } // A path as the program expects to see it, from one of the kernel's: the rootfs prefix removed. @@ -206,11 +277,11 @@ static void put_hex16(char *out, u64 v) { // bind() and connect() of a Unix socket with a path: mapped like hook/setup.c does, and hashed // into /tmp when it does not fit the 108 bytes of sun_path (the same name, so a Go server and a // program using the C library find each other) -static i64 with_sockaddr(const struct gosys_hdr *h, i64 nr, i64 a0, i64 a1, i64 a2) { +static i64 with_sockaddr(const struct gosys_hdr *h, u32 *held, i64 nr, i64 a0, i64 a1, i64 a2) { const unsigned char *sa = (const unsigned char *)a1; if (!sa || a2 < 4 || a2 > 128 || sa[0] != 1 /* AF_UNIX */ || sa[1] != 0 || sa[2] != '/') return sc(nr, a0, a1, a2, 0, 0, 0); - char *slot = take_slot(h); + char *slot = take_slot(h, held); char *orig = slot + SLOT_PATH_A; u64 max = (u64)a2 - 2; if (max > 108) max = 108; @@ -287,13 +358,13 @@ static i64 exec_host(const struct gosys_hdr *h, char *slot, const char *p, i64 a // Cortex is started (Ubuntu's dynamic programs through ld.so, scripts through their interpreter, // static programs patched first), with the argv[0] the caller chose. Without bash it is env, // which cannot set argv[0]. The path is passed as the caller wrote it. -static i64 with_execve(const struct gosys_hdr *h, i64 a0, i64 a1, i64 a2) { +static i64 with_execve(const struct gosys_hdr *h, u32 *held, i64 a0, i64 a1, i64 a2) { const char *path = (const char *)a0; char *const *argv = (char *const *)a1; char *const *envp = (char *const *)a2; if (!path || path[0] == '\0') return sc(NR_EXECVE, a0, a1, a2, 0, 0, 0); - char *slot = take_slot(h); + char *slot = take_slot(h, held); const char *p = path[0] == '/' ? rw_path(h, path, slot + SLOT_PATH_A, SLOT_PATH_CAP) : path; int wrap = h->ldso_len != 0 && h->runner_len != 0 && (path[0] != '/' || in_root(h, p)); if (!wrap) { @@ -359,7 +430,7 @@ static i64 with_execve(const struct gosys_hdr *h, i64 a0, i64 a1, i64 a2) { // Rewrites the path arguments `i1` and `i2` (an index into a[], -1: none) that are absolute, makes // the call, and shows the program the paths it expects in what the call returns. -static i64 with_paths(const struct gosys_hdr *h, i64 nr, i64 *a, int i1, int i2) { +static i64 with_paths(const struct gosys_hdr *h, u32 *held, i64 nr, i64 *a, int i1, int i2) { char *slot = 0; int proc_link = 0; int idx[2] = { i1, i2 }; @@ -368,7 +439,7 @@ static i64 with_paths(const struct gosys_hdr *h, i64 nr, i64 *a, int i1, int i2) if (i < 0) continue; const char *p = (const char *)a[i]; if (!p || p[0] != '/') continue; - if (!slot) slot = take_slot(h); + if (!slot) slot = take_slot(h, held); if (nr == NR_READLINKAT && k == 0 && pre(p, "/proc", 5)) proc_link = 1; a[i] = (i64)rw_path(h, p, slot + (k ? SLOT_PATH_B : SLOT_PATH_A), SLOT_PATH_CAP); } @@ -377,10 +448,8 @@ static i64 with_paths(const struct gosys_hdr *h, i64 nr, i64 *a, int i1, int i2) return r; } -__attribute__((section(".text.cxgo_entry"), used, noinline)) -i64 cxgo_entry(i64 a0, i64 a1, i64 a2, i64 a3, i64 a4, i64 a5, i64 unused, i64 nr) { - (void)unused; - const struct gosys_hdr *h = hdr(); +// The call itself; a slot that it takes is given back by cxgo_entry +static i64 handle(const struct gosys_hdr *h, u32 *held, i64 nr, i64 a0, i64 a1, i64 a2, i64 a3, i64 a4, i64 a5) { i64 a[6] = { a0, a1, a2, a3, a4, a5 }; switch (nr) { case NR_OPENAT: case NR_FACCESSAT: case NR_FACCESSAT2: case NR_FCHMODAT: case NR_FCHMODAT2: @@ -401,24 +470,41 @@ i64 cxgo_entry(i64 a0, i64 a1, i64 a2, i64 a3, i64 a4, i64 a5, i64 unused, i64 n case NR_FCHOWNAT: case NR_NEWFSTATAT: case NR_STATX: case NR_READLINKAT: case NR_MKDIRAT: case NR_MKNODAT: case NR_UNLINKAT: case NR_UTIMENSAT: case NR_OPENAT2: case NR_NAME_TO_HANDLE_AT: case NR_INOTIFY_ADD_WATCH: - return with_paths(h, nr, a, 1, -1); + return with_paths(h, held, nr, a, 1, -1); case NR_CHDIR: case NR_CHROOT: case NR_STATFS: case NR_TRUNCATE: case NR_SETXATTR: case NR_LSETXATTR: case NR_GETXATTR: case NR_LGETXATTR: case NR_LISTXATTR: case NR_LLISTXATTR: case NR_REMOVEXATTR: case NR_LREMOVEXATTR: - return with_paths(h, nr, a, 0, -1); + return with_paths(h, held, nr, a, 0, -1); case NR_RENAMEAT: case NR_RENAMEAT2: case NR_LINKAT: - return with_paths(h, nr, a, 1, 3); + return with_paths(h, held, nr, a, 1, 3); case NR_SYMLINKAT: // the target is data, only the link is a path - return with_paths(h, nr, a, 2, -1); + return with_paths(h, held, nr, a, 2, -1); case NR_GETCWD: { i64 r = sc(nr, a0, a1, 0, 0, 0, 0); return r > 0 ? unmap_prefix(h, (char *)a0, r, 1) : r; } case NR_BIND: case NR_CONNECT: - return with_sockaddr(h, nr, a0, a1, a2); - case NR_EXECVE: - return with_execve(h, a0, a1, a2); + return with_sockaddr(h, held, nr, a0, a1, a2); + case NR_EXECVE: { + // Counted while it is on its way: when it works it does not return (see execs_on_their_way) + u32 *count = execs_on_their_way(h); + __atomic_fetch_add(count, 1, __ATOMIC_RELAXED); + i64 r = with_execve(h, held, a0, a1, a2); + count_down(count); // it did return: it failed, and cxgo_entry gives its slot back + return r; + } default: return sc(nr, a0, a1, a2, a3, a4, a5); } } + +__attribute__((section(".text.cxgo_entry"), used, noinline)) +i64 cxgo_entry(i64 a0, i64 a1, i64 a2, i64 a3, i64 a4, i64 a5, i64 unused, i64 nr) { + (void)unused; + const struct gosys_hdr *h = hdr(); + u32 held = NO_SLOT; + reclaim_left_slots(h); + i64 r = handle(h, &held, nr, a0, a1, a2, a3, a4, a5); + put_slot(h, held); + return r; +} diff --git a/app/src/main/cpp/gosys/gosys_abi.h b/app/src/main/cpp/gosys/gosys_abi.h index a87773e..20b4cbc 100644 --- a/app/src/main/cpp/gosys/gosys_abi.h +++ b/app/src/main/cpp/gosys/gosys_abi.h @@ -15,15 +15,21 @@ #include #ifndef GOSYS_VERSION -#define GOSYS_VERSION 1u // raised when the code or its header changes: patched programs are patched again +#define GOSYS_VERSION 3u // raised when the code or its header changes: patched programs are patched again #endif -// The scratch memory for rewritten paths: a counter and a ring of slots, in zero-filled memory the -// patcher appends to the program's data segment (so nothing is allocated per call and nothing -// leaks when a vfork child execs). A slot is only needed until the kernel has copied its strings. -#define GOSYS_SLOTS 16u +// The scratch memory for rewritten paths: a small state area and 64 slots, in zero-filled memory the +// patcher appends to the program's data segment (so nothing is allocated per call). A slot belongs to +// the thread that took it until its call is over: a word in the state area for each slot says +// which goroutine (x28) has it. A slot that a vfork child kept by a successful execve is given back +// at the next call of the goroutine that made the child (the state area counts the execve calls +// that have not returned, [8]). +// (A ring of slots shared by every call was tried first: a thread that was descheduled for a +// moment between taking a slot and the call let sixteen others go round the ring and overwrite its +// path, so a Go program opened another file than it asked for.) +#define GOSYS_SLOTS 64u #define GOSYS_SLOT_SIZE 32768u // two paths (4K each), an argv array (8K), an environment array (16K) -#define GOSYS_STATE_SIZE 64u +#define GOSYS_STATE_SIZE 1024u // [4] rewriting mode, [8] execve calls on their way, [64 + 8 * slot] who has the slot (0: nobody) #define GOSYS_SCRATCH_SIZE (GOSYS_STATE_SIZE + GOSYS_SLOTS * GOSYS_SLOT_SIZE) #define GOSYS_THUNK_BYTES 256u // room for the small code that connects a call site to the code diff --git a/app/src/main/cpp/hook/env.c b/app/src/main/cpp/hook/env.c index 6cadeb6..28b2a3a 100644 --- a/app/src/main/cpp/hook/env.c +++ b/app/src/main/cpp/hook/env.c @@ -13,10 +13,11 @@ static char **clean_env_for_system(char *const envp[]) { int dst = 0; int has_path = 0; for (int i = 0; i < count; i++) { - if (strncmp(envp[i], "LD_PRELOAD=", 11) != 0 && - strncmp(envp[i], "LD_LIBRARY_PATH=", 16) != 0 && - strncmp(envp[i], "GLIBC_TUNABLES=", 15) != 0) { - if (strncmp(envp[i], "PATH=", 5) == 0) { + char first = envp[i][0]; + if (!((first == 'L' && (strncmp(envp[i], "LD_PRELOAD=", 11) == 0 || + strncmp(envp[i], "LD_LIBRARY_PATH=", 16) == 0)) || + (first == 'G' && strncmp(envp[i], "GLIBC_TUNABLES=", 15) == 0))) { + if (first == 'P' && strncmp(envp[i], "PATH=", 5) == 0) { has_path = 1; size_t plen = strlen(envp[i]); char *new_path = malloc(plen + 64); @@ -56,31 +57,41 @@ static char **prepare_cortex_env(char *const envp[], const char *real_exe) { const char *chosen_real_exe = (real_exe && real_exe[0] != '\0') ? real_exe : (g_real_exe[0] != '\0' ? g_real_exe : NULL); + // Every variable is looked at on every exec, so only the names that start with its first letter + // are compared (this was 13 strncmp() calls for each of the 30 or so variables) + int has_nt = 0; while (envp && envp[count]) { - if (strncmp(envp[count], "LD_PRELOAD=", 11) == 0) { - has_preload = 1; - } else if (strncmp(envp[count], "LD_LIBRARY_PATH=", 16) == 0) { - has_ld_library_path = 1; - } else if (strncmp(envp[count], "CORTEX_ROOT=", 12) == 0) { - has_root = 1; - } else if (strncmp(envp[count], "GLIBC_TUNABLES=", 15) == 0) { - has_tunables = 1; - } else if (strncmp(envp[count], "PATH=", 5) == 0) { - has_path = 1; - } else if (strncmp(envp[count], "TMPDIR=", 7) == 0) { - has_tmp = 1; - } else if (strncmp(envp[count], "DPKG_DEB_THREADS_MAX=", 21) == 0) { - has_threads_max = 1; - } else if (strncmp(envp[count], "XZ_OPT=", 7) == 0) { - has_xz_opt = 1; - } else if (strncmp(envp[count], "XZ_DEFAULTS=", 12) == 0) { - has_xz_defaults = 1; - } else if (strncmp(envp[count], "DEBIAN_FRONTEND=", 16) == 0) { - has_frontend = 1; - } else if (strncmp(envp[count], "DEBCONF_FRONTEND=", 17) == 0) { - has_debconf_frontend = 1; - } else if (strncmp(envp[count], "DEBCONF_NONINTERACTIVE_SEEN=", 28) == 0) { - has_debconf_seen = 1; + const char *e = envp[count]; + switch (e[0]) { + case 'L': + if (strncmp(e, "LD_PRELOAD=", 11) == 0) has_preload = 1; + else if (strncmp(e, "LD_LIBRARY_PATH=", 16) == 0) has_ld_library_path = 1; + break; + case 'C': + if (strncmp(e, "CORTEX_ROOT=", 12) == 0) has_root = 1; + else if (strncmp(e, "CORTEX_NT=", 10) == 0) has_nt = 1; + break; + case 'G': + if (strncmp(e, "GLIBC_TUNABLES=", 15) == 0) has_tunables = 1; + break; + case 'P': + if (strncmp(e, "PATH=", 5) == 0) has_path = 1; + break; + case 'T': + if (strncmp(e, "TMPDIR=", 7) == 0) has_tmp = 1; + break; + case 'X': + if (strncmp(e, "XZ_OPT=", 7) == 0) has_xz_opt = 1; + else if (strncmp(e, "XZ_DEFAULTS=", 12) == 0) has_xz_defaults = 1; + break; + case 'D': + if (strncmp(e, "DPKG_DEB_THREADS_MAX=", 21) == 0) has_threads_max = 1; + else if (strncmp(e, "DEBIAN_FRONTEND=", 16) == 0) has_frontend = 1; + else if (strncmp(e, "DEBCONF_FRONTEND=", 17) == 0) has_debconf_frontend = 1; + else if (strncmp(e, "DEBCONF_NONINTERACTIVE_SEEN=", 28) == 0) has_debconf_seen = 1; + break; + default: + break; } count++; } @@ -89,7 +100,7 @@ static char **prepare_cortex_env(char *const envp[], const char *real_exe) { int dst = 0; for (int i = 0; i < count; i++) { // If we have an explicit real executable, drop the inherited entry to replace with the fresh target - if (chosen_real_exe && strncmp(envp[i], "CORTEX_REAL_EXE=", 16) == 0) { + if (chosen_real_exe && envp[i][0] == 'C' && strncmp(envp[i], "CORTEX_REAL_EXE=", 16) == 0) { continue; } new_env[dst++] = envp[i]; @@ -137,6 +148,11 @@ static char **prepare_cortex_env(char *const envp[], const char *real_exe) { new_env[dst++] = str; } } + // This process looked for the ARM translator's files and found none: the programs it starts + // need not look again (it takes a look at /proc/self/fd in each of them, see privileged.c) + if (!has_nt && g_no_translator) { + new_env[dst++] = strdup("CORTEX_NT=0"); + } if (!has_tunables) { new_env[dst++] = strdup("GLIBC_TUNABLES=glibc.pthread.rseq=0"); } diff --git a/app/src/main/cpp/hook/exec.c b/app/src/main/cpp/hook/exec.c index c9fa4ee..660f9e9 100644 --- a/app/src/main/cpp/hook/exec.c +++ b/app/src/main/cpp/hook/exec.c @@ -39,17 +39,6 @@ static void resolve_symlinks(const char *path, char *out, size_t out_size, int d resolve_symlinks(next_path, out, out_size, depth + 1); } -static int has_pt_interp(const char *path) { - if (!path || path[0] == '\0') return 0; - int fd = open(path, O_RDONLY); - if (fd < 0) return 0; - unsigned char ehdr[64]; - ssize_t n = read(fd, ehdr, sizeof(ehdr)); - int ret = check_elf_dynamic(fd, ehdr, n); - close(fd); - return ret; -} - static char **ensure_hook_in_envp(char *const envp[], const char *real_exe) { return prepare_cortex_env(envp, real_exe); } @@ -61,7 +50,7 @@ static void build_ld_library_path(char *const envp[], char *out_buf, size_t out_ const char *caller_ld = NULL; if (envp) { for (int i = 0; envp[i] != NULL; i++) { - if (strncmp(envp[i], "LD_LIBRARY_PATH=", 16) == 0) { + if (envp[i][0] == 'L' && strncmp(envp[i], "LD_LIBRARY_PATH=", 16) == 0) { caller_ld = envp[i] + 16; break; } @@ -96,7 +85,7 @@ static void build_ld_library_path(char *const envp[], char *out_buf, size_t out_ } size_t used = cortex_append(out_buf, out_size, 0, rewritten_caller, strlen(rewritten_caller)); - append_rootfs_lib_dirs(out_buf, out_size, used, used > 0); + append_rootfs_lib_dirs_missing_from(out_buf, out_size, used, used > 0, rewritten_caller); } // The file whose header decides how to start `target`: the symlink-resolved ELF (so ld.so and diff --git a/app/src/main/cpp/hook/fileops.c b/app/src/main/cpp/hook/fileops.c index 75c8f20..3a2c11d 100644 --- a/app/src/main/cpp/hook/fileops.c +++ b/app/src/main/cpp/hook/fileops.c @@ -292,6 +292,13 @@ ssize_t __readlinkat_chk(int dirfd, const char *pathname, char *buf, size_t bufs return readlinkat(dirfd, pathname, buf, bufsiz); } +// Ubuntu's programs find Android's SELinux (enforcing) through libselinux and then ask its policy +// questions it has no answers for. The one that mattered: PAM's pam_rootok, which lets root +// change its own entries, asks "may this user be root" and was refused, so chfn, chsh and +// `adduser --gecos` (the installation of PostgreSQL) failed with "PAM: Authentication failure". +// Ubuntu does not use SELinux, so for its programs it is off, as its labels are hidden below. +int is_selinux_enabled(void) { return 0; } + // Extended attributes take the same paths as stat(). Without these hooks coreutils read // security.selinux with the Ubuntu path itself: `ls -l` printed an error line for every file // Android lacks (/etc/os-release) and showed Android's own label for the ones it has diff --git a/app/src/main/cpp/hook/files.c b/app/src/main/cpp/hook/files.c index c2e526b..ddf9d72 100644 --- a/app/src/main/cpp/hook/files.c +++ b/app/src/main/cpp/hook/files.c @@ -546,6 +546,10 @@ int __fxstatat64(int ver, int dirfd, const char *pathname, struct stat64 *statbu // Hook statx int statx(int dirfd, const char *pathname, int flags, unsigned int mask, struct statx *statxbuf) { + // Rust's standard library asks whether statx exists by calling it with a NULL path and buffer + // (EFAULT means it does). glibc's header declares both as nonnull, so the compiler would drop + // the NULL check below and read the path: every Rust program (rg, rustc, cargo...) crashed. + __asm__ volatile("" : "+r"(pathname), "+r"(statxbuf)); static int (*orig_statx)(int, const char *, int, unsigned int, struct statx *) = NULL; if (!orig_statx) orig_statx = (int (*)(int, const char *, int, unsigned int, struct statx *))dlsym(RTLD_NEXT, "statx"); char buf[PATH_MAX]; diff --git a/app/src/main/cpp/hook/identity.c b/app/src/main/cpp/hook/identity.c index 615d6eb..03f2675 100644 --- a/app/src/main/cpp/hook/identity.c +++ b/app/src/main/cpp/hook/identity.c @@ -530,6 +530,101 @@ static int cortex_lookup_passwd(const char *search_name, uid_t search_uid, int b return 0; } +#ifndef CORTEX_HOST_TEST +// The shadow database (/etc/shadow) of the rootfs. libc reads the real /etc/shadow of the machine, +// which Android does not have: pam_unix's account check for root found no entry and answered +// "Authentication failure", so chfn, chsh and `adduser --gecos` (the installation of PostgreSQL) +// failed. The fields are "name:hash:lastchange:min:max:warn:inactive:expire:flag", numbers empty +// when unset (libc stores -1 then). +static int cortex_lookup_shadow(const char *name, struct spwd *sp, char *buf, size_t buflen) { + if (!name || !sp || !buf) return EINVAL; + char path_buf[PATH_MAX]; + const char *path = rewrite_path("/etc/shadow", path_buf, sizeof(path_buf)); + static int (*orig_open)(const char *, int, ...) = NULL; + if (!orig_open) orig_open = (int (*)(const char *, int, ...))dlsym(RTLD_NEXT, "open"); + int fd = (orig_open && path) ? orig_open(path, O_RDONLY | O_CLOEXEC) : -1; + if (fd < 0) return ENOENT; + FILE *f = fdopen(fd, "r"); + if (!f) { + close(fd); + return ENOENT; + } + size_t name_len = strlen(name); + char line[1024]; + int err = ENOENT; + while (fgets(line, sizeof(line), f)) { + if (strncmp(line, name, name_len) != 0 || line[name_len] != ':') continue; + line[strcspn(line, "\r\n")] = '\0'; + char *fields[9] = {0}; + int n = 0; + char *cursor = line; + while (n < 9) { + fields[n++] = cursor; + char *colon = strchr(cursor, ':'); + if (!colon) break; + *colon = '\0'; + cursor = colon + 1; + } + size_t need = strlen(fields[0]) + 1 + strlen(fields[1] ? fields[1] : "") + 1; + if (n < 2 || need > buflen) { + err = n < 2 ? ENOENT : ERANGE; + break; + } + char *out = buf; + sp->sp_namp = out; + memcpy(out, fields[0], strlen(fields[0]) + 1); + out += strlen(fields[0]) + 1; + sp->sp_pwdp = out; + memcpy(out, fields[1], strlen(fields[1]) + 1); + long number[6]; + for (int i = 0; i < 6; i++) { + const char *v = (2 + i < n) ? fields[2 + i] : NULL; + number[i] = (v && v[0] != '\0') ? strtol(v, NULL, 10) : -1; + } + sp->sp_lstchg = number[0]; + sp->sp_min = number[1]; + sp->sp_max = number[2]; + sp->sp_warn = number[3]; + sp->sp_inact = number[4]; + sp->sp_expire = number[5]; + sp->sp_flag = (8 < n && fields[8][0] != '\0') ? strtoul(fields[8], NULL, 10) : (unsigned long)-1; + err = 0; + break; + } + fclose(f); + return err; +} + +int getspnam_r(const char *name, struct spwd *spbuf, char *buf, size_t buflen, struct spwd **result) { + static int (*orig_getspnam_r)(const char *, struct spwd *, char *, size_t, struct spwd **) = NULL; + if (!orig_getspnam_r) orig_getspnam_r = (int (*)(const char *, struct spwd *, char *, size_t, struct spwd **))dlsym(RTLD_NEXT, "getspnam_r"); + int ret = ENOENT; + if (orig_getspnam_r) { + ret = orig_getspnam_r(name, spbuf, buf, buflen, result); + if ((ret == 0 && result && *result) || ret == ERANGE) return ret; + } + int err = cortex_lookup_shadow(name, spbuf, buf, buflen); + if (err == 0) { + if (result) *result = spbuf; + return 0; + } + if (result) *result = NULL; + return err == ENOENT ? (ret != 0 ? ret : ENOENT) : err; +} + +struct spwd *getspnam(const char *name) { + static struct spwd entry; + static char entry_buf[1024]; + struct spwd *found = NULL; + int err = getspnam_r(name, &entry, entry_buf, sizeof(entry_buf), &found); + if (err != 0) { + errno = err; + return NULL; + } + return found; +} +#endif /* !CORTEX_HOST_TEST */ + static struct group s_grp_storage; static char s_grp_buf[2048]; static struct passwd s_pwd_storage; diff --git a/app/src/main/cpp/hook/net.c b/app/src/main/cpp/hook/net.c index a5c5a60..acf86ea 100644 --- a/app/src/main/cpp/hook/net.c +++ b/app/src/main/cpp/hook/net.c @@ -272,7 +272,6 @@ static inline int is_loopback_dns(const struct sockaddr *addr, socklen_t addrlen static in_addr_t get_primary_dns(void) { static in_addr_t cached_dns = 0; static time_t last_check = 0; - static time_t last_resolv_mtime = 0; static pthread_mutex_t dns_mutex = PTHREAD_MUTEX_INITIALIZER; struct timespec ts; @@ -324,7 +323,6 @@ static in_addr_t get_primary_dns(void) { struct stat st; if (stat(resolv_path, &st) == 0) { - last_resolv_mtime = st.st_mtime; if (new_dns == 0) { FILE *f = fopen(resolv_path, "r"); if (f) { diff --git a/app/src/main/cpp/hook/paths.c b/app/src/main/cpp/hook/paths.c index 25552f4..c81dd03 100644 --- a/app/src/main/cpp/hook/paths.c +++ b/app/src/main/cpp/hook/paths.c @@ -280,13 +280,42 @@ static const char *const k_rootfs_lib_dirs[] = { "/usr/lib/arm-linux-gnueabihf/systemd", "/lib/arm-linux-gnueabihf/systemd", }; +// Whether the ':'-separated list holds the directory dir[0..dir_len) as one of its entries. +static int lib_dir_list_has(const char *list, const char *dir, size_t dir_len) { + if (!list) return 0; + for (const char *p = list; *p;) { + const char *end = strchr(p, ':'); + size_t len = end ? (size_t)(end - p) : strlen(p); + if (len == dir_len && memcmp(p, dir, len) == 0) return 1; + if (!end) break; + p = end + 1; + } + return 0; +} + // Appends the rootfs library directories as a ':'-separated list (with a leading ':' when -// `separate` is set). Returns the new length. -static size_t append_rootfs_lib_dirs(char *out, size_t outsz, size_t used, int separate) { +// `separate` is set), leaving out those that `have` (a list too, or NULL) names already: the +// caller's LD_LIBRARY_PATH is usually this very list, and ld.so spends time on every directory of +// the list it is given, in every program that is started, and tries it again for each library that +// is not found. Returns the new length. +static size_t append_rootfs_lib_dirs_missing_from(char *out, size_t outsz, size_t used, int separate, const char *have) { for (size_t i = 0; i < sizeof(k_rootfs_lib_dirs) / sizeof(k_rootfs_lib_dirs[0]); i++) { - if (separate || i > 0) used = cortex_append(out, outsz, used, ":", 1); + size_t dir_len = strlen(k_rootfs_lib_dirs[i]); + if (have && have[0] != '\0') { + char full[PATH_MAX]; + size_t n = cortex_append(full, sizeof(full), 0, g_cortex_root, g_cortex_root_len); + n = cortex_append(full, sizeof(full), n, k_rootfs_lib_dirs[i], dir_len); + if (lib_dir_list_has(have, full, n)) continue; + } + if (separate) used = cortex_append(out, outsz, used, ":", 1); + separate = 1; used = cortex_append(out, outsz, used, g_cortex_root, g_cortex_root_len); - used = cortex_append(out, outsz, used, k_rootfs_lib_dirs[i], strlen(k_rootfs_lib_dirs[i])); + used = cortex_append(out, outsz, used, k_rootfs_lib_dirs[i], dir_len); } return used; } + +// All of them. +static size_t append_rootfs_lib_dirs(char *out, size_t outsz, size_t used, int separate) { + return append_rootfs_lib_dirs_missing_from(out, outsz, used, separate, NULL); +} diff --git a/app/src/main/cpp/hook/privileged.c b/app/src/main/cpp/hook/privileged.c index 62e1cc0..8554d4e 100644 --- a/app/src/main/cpp/hook/privileged.c +++ b/app/src/main/cpp/hook/privileged.c @@ -44,9 +44,56 @@ static int for_each_range_without(unsigned int first, unsigned int last, const i // ndk_translation's file is "/memfd:child"; any memory file counts in a process whose executable is // a translator's runner (houdini). ndk_translation shows a program its own path as /proc/self/exe, // so the name is what tells there. +// +// This runs at the start of every program, so it costs three system calls on a phone that runs ARM +// code itself: the descriptors that are open (/proc/self/fd, read at once) are all that are asked +// about, where asking about each of the numbers from 3 to 63 was 61 readlink() calls in every +// process that was started. +// Set once this process knows that it has none (it looked, or was told by CORTEX_NT=0 in its +// environment, which prepare_cortex_env hands on to the programs it starts) +static int g_no_translator = 0; + __attribute__((constructor(102))) static void find_translator_files(void) { + // The first program of a session looks; the ones it starts are told (a translator is there for + // all the programs of a machine or for none, and where it is not known they look as before) + const char *known = getenv("CORTEX_NT"); + if (known && known[0] == '0' && known[1] == '\0') { + g_no_translator = 1; + return; + } ssize_t (*real_readlink)(const char *, char *, size_t) = (ssize_t (*)(const char *, char *, size_t))dlsym(RTLD_NEXT, "readlink"); - if (!real_readlink) return; + int (*real_open)(const char *, int, ...) = (int (*)(const char *, int, ...))dlsym(RTLD_NEXT, "open"); + int (*real_close)(int) = (int (*)(int))dlsym(RTLD_NEXT, "close"); + ssize_t (*real_getdents64)(int, void *, size_t) = (ssize_t (*)(int, void *, size_t))dlsym(RTLD_NEXT, "getdents64"); + if (!real_readlink || !real_open || !real_close) return; + int dir = real_getdents64 ? real_open("/proc/self/fd", O_RDONLY | O_DIRECTORY | O_CLOEXEC) : -1; + if (dir < 0) return; + // The numbers of the descriptors open now, those from 3 to 63 (the directory's own left out) + char entries[2048]; + unsigned char open_fds[64]; + int nopen = 0; + for (;;) { + long n = real_getdents64(dir, entries, sizeof(entries)); + if (n <= 0) break; + for (long off = 0; off < n;) { + // struct linux_dirent64: d_ino (8), d_off (8), d_reclen (2), d_type (1), then d_name + unsigned short reclen; + memcpy(&reclen, entries + off + 16, sizeof(reclen)); + const char *name = entries + off + 19; + off += reclen ? reclen : n; // a record without a length ends the buffer + if (name[0] < '0' || name[0] > '9') continue; // "." and ".." + int fd = atoi(name); + if (fd >= 3 && fd < 64 && fd != dir) open_fds[nopen++] = (unsigned char)fd; + } + // A short answer is the whole directory; asking again would only be told "no more" + if ((size_t)n + 64 <= sizeof(entries)) break; + } + real_close(dir); + if (nopen == 0) { + g_no_translator = 1; + return; + } + char path[PATH_MAX]; int runner = 0; ssize_t n = real_readlink("/proc/self/exe", path, sizeof(path) - 1); @@ -56,16 +103,27 @@ __attribute__((constructor(102))) static void find_translator_files(void) { base = base ? base + 1 : path; runner = strncmp(base, "ndk_translation", 15) == 0 || strncmp(base, "houdini", 7) == 0; } - for (int fd = 3; fd < 64 && g_kept_fd_count < CORTEX_MAX_KEPT_FDS; fd++) { + // In ascending order, as the list is kept + for (int i = 0; i < nopen - 1; i++) { + for (int j = i + 1; j < nopen; j++) { + if (open_fds[j] < open_fds[i]) { + unsigned char t = open_fds[i]; + open_fds[i] = open_fds[j]; + open_fds[j] = t; + } + } + } + for (int i = 0; i < nopen && g_kept_fd_count < CORTEX_MAX_KEPT_FDS; i++) { char link[32], target[64]; - snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + snprintf(link, sizeof(link), "/proc/self/fd/%d", open_fds[i]); ssize_t m = real_readlink(link, target, sizeof(target) - 1); if (m <= 0) continue; target[m] = '\0'; if (strncmp(target, "/memfd:child", 12) == 0 || (runner && strncmp(target, "/memfd:", 7) == 0)) { - g_kept_fds[g_kept_fd_count++] = fd; + g_kept_fds[g_kept_fd_count++] = open_fds[i]; } } + if (g_kept_fd_count == 0) g_no_translator = 1; } int chown(const char *pathname, uid_t owner, gid_t group) { (void)pathname; (void)owner; (void)group; return 0; } diff --git a/app/src/main/cpp/hook/setup.c b/app/src/main/cpp/hook/setup.c index 2b05f00..d719583 100644 --- a/app/src/main/cpp/hook/setup.c +++ b/app/src/main/cpp/hook/setup.c @@ -74,6 +74,14 @@ static void init_cortex_hook(void) { g_initialized = 1; } +// Before the program runs: init_cortex_hook() sets variables, and setenv() may move the block the +// environment is kept in. A program that had changed its environment (putenv, setenv) and passed it +// to execv() before its first call into the hook handed the exec code a block that was freed by the +// time it was read. +__attribute__((constructor(103))) static void init_cortex_hook_early(void) { + init_cortex_hook(); +} + void tzset(void) { static void (*orig_tzset)(void) = NULL; if (!orig_tzset) orig_tzset = (void (*)(void))dlsym(RTLD_NEXT, "tzset"); diff --git a/app/src/main/java/org/cortex/terminal/MainActivity.kt b/app/src/main/java/org/cortex/terminal/MainActivity.kt index ca76958..5a55e87 100644 --- a/app/src/main/java/org/cortex/terminal/MainActivity.kt +++ b/app/src/main/java/org/cortex/terminal/MainActivity.kt @@ -482,6 +482,15 @@ class MainActivity : AppCompatActivity() { val themeKey = prefs.getString("terminal_theme", "catppuccin") ?: "catppuccin" org.cortex.terminal.emulator.TerminalColor.applyTheme(themeKey) + // The cells of the sessions that were there hold the colors of the scheme they were written + // under; without this they bring the old one back wherever the terminal shows them again + // (the rows that appear when the keyboard closes) + org.cortex.terminal.emulator.TerminalColor.takeRemap()?.let { remap -> + if (::sessionManager.isInitialized) { + val all = synchronized(sessionManager.sessions) { sessionManager.sessions.toList() } + all.forEach { it.emulator.remapColors(remap) } + } + } Chrome.apply(org.cortex.terminal.emulator.TerminalColor.SCHEMES.getValue(org.cortex.terminal.emulator.TerminalColor.currentThemeId)) terminalView.applyTheme() applyChrome() diff --git a/app/src/main/java/org/cortex/terminal/emulator/TerminalBuffer.kt b/app/src/main/java/org/cortex/terminal/emulator/TerminalBuffer.kt index ec3d76d..1887afe 100644 --- a/app/src/main/java/org/cortex/terminal/emulator/TerminalBuffer.kt +++ b/app/src/main/java/org/cortex/terminal/emulator/TerminalBuffer.kt @@ -53,6 +53,15 @@ class TerminalBuffer(var rows: Int, var cols: Int, private val maxHistory: Int = */ var reflowOnResize = true + /** Gives the screen, the other screen, the scrollback and the pen the colors of a new scheme. */ + fun remapColors(remap: TerminalColor.Remap) { + for (row in screen) row.remapColors(remap) + alternateScreen?.forEach { it.remapColors(remap) } + for (row in history) row.remapColors(remap) + currentFg = remap.mapFg(currentFg) + currentBg = remap.mapBg(currentBg) + } + fun resize(newRows: Int, newCols: Int) { if (newRows == rows && newCols == cols) return if (newRows <= 0 || newCols <= 0) return diff --git a/app/src/main/java/org/cortex/terminal/emulator/TerminalColor.kt b/app/src/main/java/org/cortex/terminal/emulator/TerminalColor.kt index 262b4df..d3c26aa 100644 --- a/app/src/main/java/org/cortex/terminal/emulator/TerminalColor.kt +++ b/app/src/main/java/org/cortex/terminal/emulator/TerminalColor.kt @@ -351,8 +351,13 @@ object TerminalColor { val ANSI_COLORS = IntArray(16) val COLOR_PALETTE = IntArray(256) + // The colors of the scheme that the cells of the sessions have been brought to (declared before + // the init block: properties and init blocks of an object run in the order they are written) + private var held = IntArray(18) + init { applyTheme("catppuccin") + held = inUse() } fun rebuildPalette() { @@ -374,6 +379,62 @@ object TerminalColor { } } + /** + * What a change of scheme makes of the colors that cells hold. A cell keeps the color the scheme + * had when it was written (as a number, not as "the default"), so the screen and the scrollback + * of a session that was there before the change would go on showing the old scheme: its + * background came back as a block when the keyboard closed and the terminal grew into the rows + * it had hidden. Only the default and the 16 ANSI colors change with a scheme; the other 240 and + * the 24-bit ones are the same in all of them. + * + * A number does not say which of them it was: in Gruvbox the default background is also ANSI + * black and the default foreground ANSI white. The colors are therefore tried in the order that + * is likely for where the number is: as a foreground the default foreground, then the ANSI ones, + * the default background last (text in the color of the background would not be seen); as a + * background the default background, then the ANSI ones, the default foreground last. A cell + * that is ANSI black on Gruvbox's black background is still seen as the background, and a + * 24-bit color that equals one of them is changed with it. + */ + class Remap internal constructor( + private val fgFrom: IntArray, private val fgTo: IntArray, + private val bgFrom: IntArray, private val bgTo: IntArray + ) { + fun mapFg(color: Int): Int { + for (i in fgFrom.indices) if (fgFrom[i] == color) return fgTo[i] + return color + } + + fun mapBg(color: Int): Int { + for (i in bgFrom.indices) if (bgFrom[i] == color) return bgTo[i] + return color + } + } + + // [0] the default foreground, [1] the default background, [2..17] the ANSI colors + private fun inUse() = intArrayOf(DEFAULT_FG, DEFAULT_BG, *ANSI_COLORS) + + /** + * What to do with the cells of the sessions to bring them to the scheme in use, null when they are + * there. Whoever gets it does that at once for all sessions: the next answer starts from here. (The + * settings change the scheme in use for themselves, so the sessions are not told by a change.) + */ + fun takeRemap(): Remap? { + val now = inUse() + if (now.indices.all { held[it] == now[it] }) { + held = now + return null + } + // The order of the colors, see Remap + val fgOrder = (listOf(0) + (2..17) + listOf(1)).filter { held[it] != now[it] } + val bgOrder = (listOf(1) + (2..17) + listOf(0)).filter { held[it] != now[it] } + val remap = Remap( + IntArray(fgOrder.size) { held[fgOrder[it]] }, IntArray(fgOrder.size) { now[fgOrder[it]] }, + IntArray(bgOrder.size) { held[bgOrder[it]] }, IntArray(bgOrder.size) { now[bgOrder[it]] } + ) + held = now + return remap + } + fun applyTheme(themeId: String) { val scheme = SCHEMES[themeId] ?: SCHEMES["catppuccin"] ?: return currentThemeId = scheme.id diff --git a/app/src/main/java/org/cortex/terminal/emulator/TerminalEmulator.kt b/app/src/main/java/org/cortex/terminal/emulator/TerminalEmulator.kt index d4ce402..b9cc965 100644 --- a/app/src/main/java/org/cortex/terminal/emulator/TerminalEmulator.kt +++ b/app/src/main/java/org/cortex/terminal/emulator/TerminalEmulator.kt @@ -185,6 +185,14 @@ class TerminalEmulator( onScreenUpdate?.invoke() } + /** What is on the screen and in the scrollback in the colors of a new scheme (see [TerminalColor.Remap]). */ + fun remapColors(remap: TerminalColor.Remap) { + lock.withLock { + buffer.remapColors(remap) + } + onScreenUpdate?.invoke() + } + private val utf8ByteBuffer: ByteBuffer = ByteBuffer.allocate(16384) fun processInput(bytes: ByteArray, offset: Int, length: Int) { diff --git a/app/src/main/java/org/cortex/terminal/emulator/TerminalRow.kt b/app/src/main/java/org/cortex/terminal/emulator/TerminalRow.kt index e41abba..497a6af 100644 --- a/app/src/main/java/org/cortex/terminal/emulator/TerminalRow.kt +++ b/app/src/main/java/org/cortex/terminal/emulator/TerminalRow.kt @@ -34,6 +34,32 @@ class TerminalRow(val cols: Int) { if (col in l.indices) l[col] = link } + /** The colors of the cells under a new scheme (see [TerminalColor.Remap]). */ + fun remapColors(remap: TerminalColor.Remap) { + if (cols == 0) return + // Cells next to each other mostly have the same colors + var lastFrom = fgColors[0] + var lastTo = remap.mapFg(lastFrom) + for (i in fgColors.indices) { + val c = fgColors[i] + if (c != lastFrom) { + lastFrom = c + lastTo = remap.mapFg(c) + } + fgColors[i] = lastTo + } + lastFrom = bgColors[0] + lastTo = remap.mapBg(lastFrom) + for (i in bgColors.indices) { + val c = bgColors[i] + if (c != lastFrom) { + lastFrom = c + lastTo = remap.mapBg(c) + } + bgColors[i] = lastTo + } + } + fun clear(fg: Int = TerminalColor.DEFAULT_FG, bg: Int = TerminalColor.DEFAULT_BG) { // One pass per array: simple fills compile to wide stores, a single loop writing seven // arrays does not. Every scrolled line clears a row. diff --git a/app/src/main/java/org/cortex/terminal/runtime/BaseUsers.kt b/app/src/main/java/org/cortex/terminal/runtime/BaseUsers.kt new file mode 100644 index 0000000..70464e8 --- /dev/null +++ b/app/src/main/java/org/cortex/terminal/runtime/BaseUsers.kt @@ -0,0 +1,67 @@ +package org.cortex.terminal.runtime + +/** + * The system users and groups of Debian's base-passwd: daemon, man, www-data, nobody and the groups + * adm, tty, shadow, staff, nogroup... Ubuntu's packages take them for granted: their scripts run + * `chown redis:adm`, `setpriv --reuid=man` or `adduser --system --group www-data`. Cortex's first + * image kept only root, daemon and cortex in /etc/passwd and /etc/group, so installing redis, nginx or + * PostgreSQL (or any package with a man page, whose man-db trigger runs as man) failed in the middle + * of dpkg's work. + * + * The lists the image was made from are in the image itself (passwd.master and group.master in + * /usr/share/base-passwd). What is missing is added after what is there; nothing that is there is changed. + */ +internal object BaseUsers { + + /** [text] with the missing entries added, and the names of the entries that were. */ + class Merged(val text: String, val added: List) + + private fun names(text: String): Set = text.lineSequence() + .filter { it.isNotEmpty() && !it.startsWith("#") } + .map { it.substringBefore(':') } + .toSet() + + /** The master list's entries as (name, fields after the password), the ones with [fields] fields or more. */ + private fun masterEntries(master: String, fields: Int): List>> = master.lineSequence() + .filter { it.isNotEmpty() && !it.startsWith("#") } + .map { it.split(':') } + .filter { it.size >= fields } + .map { it[0] to it.drop(2) } + .toList() + + private fun append(text: String, lines: List): String { + if (lines.isEmpty()) return text + val base = if (text.isEmpty() || text.endsWith("\n")) text else text + "\n" + return base + lines.joinToString("\n", postfix = "\n") + } + + /** /etc/passwd with the master's users that are not in it (as `name:x:uid:gid:gecos:dir:shell`). */ + fun mergePasswd(passwd: String, master: String): Merged { + val have = names(passwd) + val missing = masterEntries(master, 7).filter { it.first !in have } + return Merged( + append(passwd, missing.map { (name, rest) -> "$name:x:" + rest.joinToString(":") }), + missing.map { it.first } + ) + } + + /** /etc/group with the master's groups that are not in it (as `name:x:gid:members`). */ + fun mergeGroup(group: String, master: String): Merged { + val have = names(group) + val missing = masterEntries(master, 4).filter { it.first !in have } + return Merged( + append(group, missing.map { (name, rest) -> "$name:x:" + rest.joinToString(":") }), + missing.map { it.first } + ) + } + + /** [text] (/etc/shadow or /etc/gshadow) with a locked entry made by [entry] for each of [users] without one. */ + fun mergeShadow(text: String, users: List, entry: (String) -> String): String { + val have = names(text) + return append(text, users.filter { it !in have }.map(entry)) + } + + fun shadowEntry(name: String) = "$name:*:19700:0:99999:7:::" + + fun gshadowEntry(name: String) = "$name:*::" +} 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 73b1491..ca45c2d 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 = 12639 + const val CURRENT_BOOTSTRAP_VERSION = 12640 fun initializeFileSystem(context: Context) { val root = Environment.getCortexRoot(context) @@ -168,6 +168,9 @@ object BootstrapManager { File(root, "var/lib/apt/lists/partial").listFiles()?.forEach { it.delete() } } ensureMachineId(root) + // The system users and groups (a package's scripts need them): before the installation is + // marked as current, so that a session that starts at once can install packages + ensurePasswd(root, Environment.getHomeDir(context)) ensureEssentialBinaries(root, Environment.getHomeDir(context), context) ensureDynamicLinkerSymlinks(root) val versionFile = File(root, ".cortex_version") @@ -997,6 +1000,13 @@ object BootstrapManager { if (!passwdText.contains("sshd:")) { passwdText += "sshd:x:101:101:Privilege-separated SSH:/run/sshd:/usr/sbin/nologin\n" } + // The system users and groups every Ubuntu has (man, www-data, adm, tty...): packages' + // scripts use them, and an image that kept only root and cortex made redis, nginx and + // PostgreSQL fail to install + val baseDir = File(root, "usr/share/base-passwd") + fun master(name: String) = try { File(baseDir, name).takeIf { it.isFile }?.readText() ?: "" } catch (e: Exception) { "" } + val basePasswd = BaseUsers.mergePasswd(passwdText, master("passwd.master")) + passwdText = basePasswd.text FileInstaller.install(passwdFile, passwdText, 420) // 0644 val groupFile = File(etcDir, "group") @@ -1013,6 +1023,8 @@ object BootstrapManager { if (!groupText.contains("ssh:")) { groupText += "ssh:x:101:\n" } + val baseGroup = BaseUsers.mergeGroup(groupText, master("group.master")) + groupText = baseGroup.text FileInstaller.install(groupFile, groupText, 420) val shadowFile = File(etcDir, "shadow") @@ -1021,6 +1033,7 @@ object BootstrapManager { if (!shadowText.contains("cortex:")) shadowText += "cortex:*:19700:0:99999:7:::\n" if (!shadowText.contains("_ssh:")) shadowText += "_ssh:*:19700:0:99999:7:::\n" if (!shadowText.contains("sshd:")) shadowText += "sshd:*:19700:0:99999:7:::\n" + shadowText = BaseUsers.mergeShadow(shadowText, basePasswd.added, BaseUsers::shadowEntry) FileInstaller.install(shadowFile, shadowText, 420) val gshadowFile = File(etcDir, "gshadow") @@ -1029,6 +1042,7 @@ object BootstrapManager { if (!gshadowText.contains("cortex:")) gshadowText += "cortex:*::\n" if (!gshadowText.contains("_ssh:")) gshadowText += "_ssh:*::\n" if (!gshadowText.contains("ssh:")) gshadowText += "ssh:*::\n" + gshadowText = BaseUsers.mergeShadow(gshadowText, baseGroup.added, BaseUsers::gshadowEntry) FileInstaller.install(gshadowFile, gshadowText, 420) } catch (e: Exception) { Log.e(TAG, "Failed to ensure passwd/group", e) diff --git a/app/src/main/java/org/cortex/terminal/runtime/Environment.kt b/app/src/main/java/org/cortex/terminal/runtime/Environment.kt index fc44385..71c4666 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/Environment.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/Environment.kt @@ -28,7 +28,6 @@ object Environment { val root = getCortexRoot(context).absolutePath val home = getHomeDir(context).absolutePath val tmp = getTmpDir(context).absolutePath - val nativeLibs = getNativeLibDir(context).absolutePath val pathList = listOf( "/home/.local/bin", diff --git a/app/src/main/java/org/cortex/terminal/service/CortexService.kt b/app/src/main/java/org/cortex/terminal/service/CortexService.kt index 9e2d7fc..8ab9b43 100644 --- a/app/src/main/java/org/cortex/terminal/service/CortexService.kt +++ b/app/src/main/java/org/cortex/terminal/service/CortexService.kt @@ -222,7 +222,7 @@ class CortexService : Service() { } } ACTION_STOP -> { - stopForeground(true) + stopForeground(Service.STOP_FOREGROUND_REMOVE) stopSelf() return START_NOT_STICKY } @@ -279,7 +279,7 @@ class CortexService : Service() { _sessionManager = null } catch (e: Exception) {} - stopForeground(true) + stopForeground(Service.STOP_FOREGROUND_REMOVE) stopSelf() MainActivity.instance?.let { act -> @@ -382,7 +382,7 @@ class CortexService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { // Keep running in foreground if there are active sessions if (_sessionManager == null || _sessionManager?.sessions?.isEmpty() == true) { - stopForeground(true) + stopForeground(Service.STOP_FOREGROUND_REMOVE) stopSelf() } super.onTaskRemoved(rootIntent) diff --git a/app/src/main/java/org/cortex/terminal/service/LocalServers.kt b/app/src/main/java/org/cortex/terminal/service/LocalServers.kt index da20b58..bad49da 100644 --- a/app/src/main/java/org/cortex/terminal/service/LocalServers.kt +++ b/app/src/main/java/org/cortex/terminal/service/LocalServers.kt @@ -56,9 +56,34 @@ class LocalServers(private val context: Context, private val root: File) { if (executor != null) return executor = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "Cortex-LocalServers").apply { isDaemon = true } - }.also { it.scheduleWithFixedDelay({ safely { poll() } }, 1, POLL_SECONDS, TimeUnit.SECONDS) } + } + scheduleNext(1) + } + + // The next look: soon while there is something to follow (or /proc/net can be read, where servers + // the hook does not see are found), seldom when there is nothing: the hook's reports wake the + // watch by themselves (FileObserver), and every look is a wake-up of the phone + private fun scheduleNext(delaySeconds: Long) { + val running = executor ?: return + try { + // One look is scheduled at a time: a new one replaces it (a port that was just reported + // is looked at again in a few seconds, not when the idle look comes) + nextLook?.cancel(false) + nextLook = running.schedule({ + safely { poll() } + scheduleNext(nextDelaySeconds()) + }, delaySeconds, TimeUnit.SECONDS) + } catch (_: java.util.concurrent.RejectedExecutionException) { + } } + @Volatile + private var nextLook: java.util.concurrent.ScheduledFuture<*>? = null + + private fun nextDelaySeconds(): Long = + if (pending.isNotEmpty() || shown.isNotEmpty() || SystemClock.elapsedRealtime() >= procRetryAt) POLL_SECONDS + else IDLE_POLL_SECONDS + @Synchronized fun stop() { observer?.stopWatching() @@ -115,6 +140,8 @@ class LocalServers(private val context: Context, private val root: File) { val program = readReport(File(dir, port.toString()))?.second addPending(port, program) check(port) + // Not answering yet: the next look is soon, whatever the idle one was waiting for + if (pending.isNotEmpty()) scheduleNext(POLL_SECONDS) } private fun addPending(port: Int, program: String?) { @@ -194,7 +221,12 @@ class LocalServers(private val context: Context, private val root: File) { null } + // When /proc/net may be asked again (Android 10 and newer refuse it, and every refusal is a line + // in the system log and a wake-up for nothing) + private var procRetryAt = 0L + private fun procListening(): Set? { + if (SystemClock.elapsedRealtime() < procRetryAt) return null val uids = if (RootMode.isEnabled(context)) setOf(Process.myUid(), 0) else setOf(Process.myUid()) var any = false val ports = mutableSetOf() @@ -207,7 +239,10 @@ class LocalServers(private val context: Context, private val root: File) { any = true ports += ServerPorts.parseProcNetListening(text, uids) } - if (!any) return null + if (!any) { + procRetryAt = SystemClock.elapsedRealtime() + PROC_RETRY_MS + return null + } // Only a list that has cortex-api's own port shows the app's sockets val api = ApiServer.port return if (api == null || api in ports) ports else null @@ -288,6 +323,8 @@ class LocalServers(private val context: Context, private val root: File) { const val CHANNEL_ID = "cortex_servers" const val PREF_NOTIFY_SERVERS = "notify_servers" private const val POLL_SECONDS = 3L + private const val IDLE_POLL_SECONDS = 15L + private const val PROC_RETRY_MS = 10 * 60 * 1000L private const val GIVE_UP_MS = 60_000L private const val CONNECT_TIMEOUT_MS = 500 private const val READ_TIMEOUT_MS = 1500 diff --git a/app/src/main/java/org/cortex/terminal/view/TerminalView.kt b/app/src/main/java/org/cortex/terminal/view/TerminalView.kt index 58a3541..f512c70 100644 --- a/app/src/main/java/org/cortex/terminal/view/TerminalView.kt +++ b/app/src/main/java/org/cortex/terminal/view/TerminalView.kt @@ -591,7 +591,7 @@ class TerminalView @JvmOverloads constructor( fun setTerminalTextSize(sp: Float) { terminalTextSizeSp = sp - val px = sp * resources.displayMetrics.scaledDensity + val px = sp * resources.displayMetrics.density * resources.configuration.fontScale textPaint.textSize = px measureCharDimensions() updateTerminalDimensions() diff --git a/app/src/test/java/org/cortex/terminal/emulator/TerminalSchemeChangeTest.kt b/app/src/test/java/org/cortex/terminal/emulator/TerminalSchemeChangeTest.kt new file mode 100644 index 0000000..ced279c --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/emulator/TerminalSchemeChangeTest.kt @@ -0,0 +1,147 @@ +package org.cortex.terminal.emulator + +import android.graphics.Color +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A cell keeps the color the scheme had when it was written, so a change of scheme has to be carried + * into the sessions that were there (an issue: after another scheme was chosen, the rows that + * appeared when the keyboard closed were in the old one). Colors are real here: Color.rgb needs + * Robolectric. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TerminalSchemeChangeTest { + + @Before + fun setUp() { + TerminalColor.applyTheme("catppuccin") + TerminalColor.takeRemap() + } + + @After + fun tearDown() { + TerminalColor.applyTheme("catppuccin") + TerminalColor.takeRemap() + } + + private fun feed(emulator: TerminalEmulator, text: String) { + val bytes = text.toByteArray(Charsets.UTF_8) + emulator.processInput(bytes, 0, bytes.size) + } + + private fun allRows(emulator: TerminalEmulator): List = + emulator.buffer.history.toList() + emulator.buffer.screen.toList() + + private fun rowStartingWith(emulator: TerminalEmulator, text: String): TerminalRow = + allRows(emulator).first { it.getText().startsWith(text) } + + private fun feedSample(emulator: TerminalEmulator) { + // The default colors, an ANSI one, a 256-color one and a 24-bit one, then enough lines to + // push the first ones into the scrollback + feed(emulator, "plain\r\n\u001b[31mred\u001b[0m\r\n\u001b[38;5;196mfixed\u001b[0m\r\n" + + "\u001b[48;2;1;2;3mtrue\u001b[0m\r\n" + "filler\r\n".repeat(6)) + } + + @Test + fun cellsOfTheOldSchemeTakeTheNewOne() { + val emulator = TerminalEmulator(rows = 4, cols = 20) + feedSample(emulator) + assertTrue("the first lines are in the scrollback", emulator.buffer.history.size >= 3) + val oldBg = TerminalColor.DEFAULT_BG + val fixed = TerminalColor.COLOR_PALETTE[196] + + TerminalColor.applyTheme("nord") + assertNotEquals(oldBg, TerminalColor.DEFAULT_BG) + val remap = TerminalColor.takeRemap() + assertNotNull(remap) + emulator.remapColors(remap!!) + + val plain = rowStartingWith(emulator, "plain") + assertEquals(TerminalColor.DEFAULT_FG, plain.fgColors[0]) + assertEquals(TerminalColor.DEFAULT_BG, plain.bgColors[0]) + assertEquals("the blank cells after the text too", TerminalColor.DEFAULT_BG, plain.bgColors[19]) + assertEquals(TerminalColor.ANSI_COLORS[1], rowStartingWith(emulator, "red").fgColors[0]) + assertEquals("a color of the 256 is the same in every scheme", fixed, rowStartingWith(emulator, "fixed").fgColors[0]) + assertEquals("a 24-bit color is not touched", Color.rgb(1, 2, 3), rowStartingWith(emulator, "true").bgColors[0]) + for (row in allRows(emulator)) { + for (i in 0 until row.cols) { + assertNotEquals("no cell keeps the old background", oldBg, row.bgColors[i]) + } + } + assertEquals(TerminalColor.DEFAULT_FG, emulator.buffer.currentFg) + assertEquals(TerminalColor.DEFAULT_BG, emulator.buffer.currentBg) + } + + @Test + fun aColorThatTheOldSchemeUsedForTwoThingsKeepsItsMeaning() { + // Gruvbox's background is also its ANSI black, and its foreground its ANSI white + TerminalColor.applyTheme("gruvbox") + TerminalColor.takeRemap() + assertEquals(TerminalColor.DEFAULT_BG, TerminalColor.ANSI_COLORS[0]) + val emulator = TerminalEmulator(rows = 4, cols = 20) + feed(emulator, "\u001b[30mblack text\u001b[0m\r\nplain\r\n\u001b[47mwhite ground\u001b[0m\r\n") + + TerminalColor.applyTheme("nord") + assertNotEquals(TerminalColor.DEFAULT_BG, TerminalColor.ANSI_COLORS[0]) + emulator.remapColors(TerminalColor.takeRemap()!!) + + val black = rowStartingWith(emulator, "black text") + assertEquals("black text is still ANSI black, not the color of the background", + TerminalColor.ANSI_COLORS[0], black.fgColors[0]) + assertEquals(TerminalColor.DEFAULT_BG, black.bgColors[0]) + val plain = rowStartingWith(emulator, "plain") + assertEquals(TerminalColor.DEFAULT_FG, plain.fgColors[0]) + assertEquals(TerminalColor.DEFAULT_BG, plain.bgColors[0]) + assertEquals("a white background is ANSI white, not the default foreground", + TerminalColor.ANSI_COLORS[7], rowStartingWith(emulator, "white ground").bgColors[0]) + } + + @Test + fun theSameSchemeChangesNothingAndTheChangeIsTakenOnce() { + TerminalColor.applyTheme("catppuccin") + assertNull(TerminalColor.takeRemap()) + TerminalColor.applyTheme("dracula") + assertNotNull(TerminalColor.takeRemap()) + assertNull("it is handed out once", TerminalColor.takeRemap()) + } + + @Test + fun aChangeMadeBySettingsIsStillCarriedIntoTheSessions() { + // SettingsActivity applies the scheme itself, so when the terminal's activity applies it + // again nothing changes in TerminalColor; the sessions have not been told yet + val emulator = TerminalEmulator(rows = 4, cols = 20) + feedSample(emulator) + TerminalColor.applyTheme("gruvbox") + TerminalColor.applyTheme("gruvbox") + val remap = TerminalColor.takeRemap() + assertNotNull(remap) + emulator.remapColors(remap!!) + assertEquals(TerminalColor.DEFAULT_BG, rowStartingWith(emulator, "plain").bgColors[0]) + } + + @Test + fun theScreenThatWasSetAsideIsChangedToo() { + val emulator = TerminalEmulator(rows = 4, cols = 20) + feed(emulator, "main screen\r\n") + feed(emulator, "\u001b[?1049h" + "full screen program") + TerminalColor.applyTheme("solarized_dark") + val remap = TerminalColor.takeRemap() + assertNotNull(remap) + emulator.remapColors(remap!!) + feed(emulator, "\u001b[?1049l") + val main = rowStartingWith(emulator, "main screen") + assertEquals(TerminalColor.DEFAULT_BG, main.bgColors[0]) + assertEquals(TerminalColor.DEFAULT_FG, main.fgColors[0]) + } +} diff --git a/app/src/test/java/org/cortex/terminal/runtime/BaseUsersTest.kt b/app/src/test/java/org/cortex/terminal/runtime/BaseUsersTest.kt new file mode 100644 index 0000000..eb74f5e --- /dev/null +++ b/app/src/test/java/org/cortex/terminal/runtime/BaseUsersTest.kt @@ -0,0 +1,99 @@ +package org.cortex.terminal.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BaseUsersTest { + + private val passwdMaster = """ + root:*:0:0:root:/root:/bin/bash + daemon:*:1:1:daemon:/usr/sbin:/usr/sbin/nologin + man:*:6:12:man:/var/cache/man:/usr/sbin/nologin + www-data:*:33:33:www-data:/var/www:/usr/sbin/nologin + nobody:*:65534:65534:nobody:/nonexistent:/usr/sbin/nologin + """.trimIndent() + "\n" + + private val groupMaster = """ + root:*:0: + daemon:*:1: + adm:*:4: + shadow:*:42: + users:*:100: + nogroup:*:65534: + """.trimIndent() + "\n" + + @Test + fun mergePasswd_addsTheMissingUsersAfterTheOnesThatAreThere() { + val have = "root:x:0:0:root:/data/home:/bin/bash\ncortex:x:0:0:Cortex:/data/home:/bin/bash\n_ssh:x:100:100:OpenSSH daemon:/run/sshd:/usr/sbin/nologin\n" + + val merged = BaseUsers.mergePasswd(have, passwdMaster) + + assertEquals(listOf("daemon", "man", "www-data", "nobody"), merged.added) + assertEquals( + have + + "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n" + + "man:x:6:12:man:/var/cache/man:/usr/sbin/nologin\n" + + "www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n" + + "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n", + merged.text + ) + } + + @Test + fun mergePasswd_changesNothingThatIsThere() { + // A user the user or a package changed keeps its fields, and a second merge adds nothing + val have = "root:x:0:0:root:/home:/bin/zsh\nman:x:6:12:man:/elsewhere:/bin/sh\n" + + val once = BaseUsers.mergePasswd(have, passwdMaster) + val twice = BaseUsers.mergePasswd(once.text, passwdMaster) + + assertTrue(once.text.startsWith(have)) + assertTrue(once.text.contains("man:x:6:12:man:/elsewhere:/bin/sh\n")) + assertEquals(1, Regex("^man:", RegexOption.MULTILINE).findAll(once.text).count()) + assertEquals(emptyList(), twice.added) + assertEquals(once.text, twice.text) + } + + @Test + fun mergePasswd_endsAFileWithoutANewlineBeforeAddingToIt() { + val merged = BaseUsers.mergePasswd("root:x:0:0:root:/home:/bin/bash", passwdMaster) + + assertTrue(merged.text.startsWith("root:x:0:0:root:/home:/bin/bash\ndaemon:x:1:1:")) + } + + @Test + fun mergePasswd_withoutAMasterOrAnEmptyFile() { + assertEquals("root:x:0:0\n", BaseUsers.mergePasswd("root:x:0:0\n", "").text) + assertEquals(emptyList(), BaseUsers.mergePasswd("root:x:0:0\n", "").added) + // Lines that are not entries (comments, short ones) are not taken + assertEquals(emptyList(), BaseUsers.mergePasswd("", "# a comment\nbroken:*:1\n").added) + assertEquals("", BaseUsers.mergePasswd("", "").text) + } + + @Test + fun mergeGroup_addsTheGroupsPackagesAskFor() { + val have = "root:x:0:\ncortex:x:0:\n_ssh:x:100:\nssh:x:101:\n" + + val merged = BaseUsers.mergeGroup(have, groupMaster) + + // adm (redis's `chown redis:adm`), shadow, nogroup (adduser --system)... + assertEquals(listOf("daemon", "adm", "shadow", "users", "nogroup"), merged.added) + assertTrue(merged.text.contains("adm:x:4:\n")) + assertTrue(merged.text.contains("nogroup:x:65534:\n")) + // gid 100 is _ssh's and users' too: the duplicate is allowed, the name is what packages look for + assertTrue(merged.text.contains("users:x:100:\n")) + } + + @Test + fun mergeShadow_locksTheNewAccountsOnce() { + val shadow = "root:*:19700:0:99999:7:::\n" + + val once = BaseUsers.mergeShadow(shadow, listOf("man", "root", "www-data"), BaseUsers::shadowEntry) + val twice = BaseUsers.mergeShadow(once, listOf("man", "root", "www-data"), BaseUsers::shadowEntry) + + assertEquals(shadow + "man:*:19700:0:99999:7:::\nwww-data:*:19700:0:99999:7:::\n", once) + assertEquals(once, twice) + assertEquals("adm:*::\n", BaseUsers.mergeShadow("", listOf("adm"), BaseUsers::gshadowEntry)) + } +} diff --git a/app/src/test/java/org/cortex/terminal/runtime/BootstrapManagerFlowTest.kt b/app/src/test/java/org/cortex/terminal/runtime/BootstrapManagerFlowTest.kt index 7e17da7..43ea447 100644 --- a/app/src/test/java/org/cortex/terminal/runtime/BootstrapManagerFlowTest.kt +++ b/app/src/test/java/org/cortex/terminal/runtime/BootstrapManagerFlowTest.kt @@ -62,6 +62,8 @@ class BootstrapManagerFlowTest { put("usr/bin/apt", "apt", base) put("usr/bin/mawk", "mawk", base) put("usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1", "ld.so", base) + put("usr/share/base-passwd/passwd.master", "root:*:0:0:root:/root:/bin/bash\nman:*:6:12:man:/var/cache/man:/usr/sbin/nologin\nwww-data:*:33:33:www-data:/var/www:/usr/sbin/nologin\n", base) + put("usr/share/base-passwd/group.master", "root:*:0:\nadm:*:4:\nman:*:12:\nnogroup:*:65534:\n", base) put("etc/skel/.bashrc", "# the image's .bashrc\n", base) put("etc/skel/.profile", "# the image's .profile\n", base) put("etc/profile", "if [ \"`id -u`\" -eq 0 ]; then PS1='# '; fi\n", base) @@ -135,6 +137,13 @@ class BootstrapManagerFlowTest { assertEquals("root:x:0:0:root:${home.absolutePath}:/bin/bash", passwd[0]) assertTrue(passwd.contains("cortex:x:0:0:Cortex:${home.absolutePath}:/bin/bash")) assertTrue(read("etc/group").startsWith("root:x:0:\n")) + // The system users and groups of every Ubuntu, which packages' scripts use (chown redis:adm, + // setpriv --reuid=man): once there, with locked shadow entries + assertTrue(passwd.contains("man:x:6:12:man:/var/cache/man:/usr/sbin/nologin")) + assertTrue(passwd.contains("www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin")) + assertTrue(read("etc/group").lines().containsAll(listOf("adm:x:4:", "man:x:12:", "nogroup:x:65534:"))) + assertTrue(read("etc/shadow").lines().contains("man:*:19700:0:99999:7:::")) + assertTrue(read("etc/gshadow").lines().contains("adm:*::")) val bundle = File(root, "etc/ssl/certs/ca-certificates.crt") assertArrayEquals(asset("cacert.pem"), bundle.readBytes()) diff --git a/scripts/build-gosys.sh b/scripts/build-gosys.sh index 07d1c51..3c00dc9 100755 --- a/scripts/build-gosys.sh +++ b/scripts/build-gosys.sh @@ -35,7 +35,7 @@ trap 'rm -rf "$tmp"' EXIT "${CC[@]}" -std=gnu11 -O2 -Wall -Wextra -Werror \ -ffreestanding -fno-builtin -nostdlib -fPIE -fvisibility=hidden -fno-stack-protector \ -fno-asynchronous-unwind-tables -fno-unwind-tables -fno-tree-loop-distribute-patterns \ - -mcmodel=tiny -mgeneral-regs-only -mno-outline-atomics -ffixed-x18 \ + -mcmodel=tiny -mgeneral-regs-only -mno-outline-atomics -ffixed-x18 -ffixed-x28 \ -c "$src/gosys.c" -o "$tmp/gosys.o" "${LD[@]}" -T "$src/gosys.ld" -nostdlib -o "$tmp/gosys.elf" "$tmp/gosys.o" "${OBJCOPY[@]}" -O binary "$tmp/gosys.elf" "$tmp/gosys.bin" diff --git a/tests/e2e/compat.sh b/tests/e2e/compat.sh index 8adcb1e..6a51ff4 100644 --- a/tests/e2e/compat.sh +++ b/tests/e2e/compat.sh @@ -19,6 +19,20 @@ if [ -n "${CORTEX_E2E_EMULATOR:-}" ]; then arm-sve arm-sve2 arm-sveaes arm-svepmull arm-svesha3 arm-svesm4 > /etc/gcrypt/hwf.deny fi +# CORTEX_COMPAT_ONLY="redis nginx": only those packages (names as in the table), to look closer at some +ONLY=" ${CORTEX_COMPAT_ONLY:-} " +ONLY="${ONLY//,/ }" + +# What the system's users and groups look like to the tools that ask (a package's scripts do) +{ + echo "--- id: $(id)" + echo "--- passwd: $(wc -l < /etc/passwd) lines, group: $(wc -l < /etc/group) lines" + for u in man www-data nobody _apt redis; do echo "getent passwd $u: $(getent passwd $u || echo none)"; done + for g in adm tty shadow staff nogroup; do echo "getent group $g: $(getent group $g || echo none)"; done + echo "--- setpriv --reuid=man: $(setpriv --reuid=man id -u 2>&1)" + ls -la /etc/passwd* /etc/group* 2>&1 +} > "$OUT/diag-users.txt" 2>&1 + W="${CORTEX_COMPAT_DIR:-/tmp/cortex-compat}" rm -rf "$W" mkdir -p "$W" @@ -26,8 +40,11 @@ cd "$W" || exit 1 # package NAME "APT PACKAGES" EXPECTED COMMAND: installs the packages, runs COMMAND (bash) and # records whether it succeeded with EXPECTED in its output, and how long the whole took +RAN=" " package() { local name="$1" packages="$2" expected="$3" command="$4" out s started=$SECONDS + if [ "$ONLY" != " " ] && [[ "$ONLY" != *" $name "* ]]; then return; fi + RAN="$RAN$name " printf '\033[36m…\033[0m %s\n' "$name" ( echo "\$ apt-get install -y $packages" @@ -35,7 +52,7 @@ package() { # terminal: apt-get changes the terminal's settings at its end and is stopped (SIGTTOU) for good. # So: --foreground, and no terminal as input. # shellcheck disable=SC2086 # the package list - timeout --foreground 900 apt-get install -y --no-install-recommends $packages < /dev/null 2>&1 | tail -n 15 + timeout --foreground 900 apt-get install -y --no-install-recommends $packages < /dev/null 2>&1 | tail -n 250 s=${PIPESTATUS[0]} [ "$s" = 0 ] || { echo "apt-get failed ($s)"; exit 10; } echo "\$ $command" @@ -107,12 +124,24 @@ package fish "fish" "hi" 'fish -c "echo hi"' # Servers package redis "redis-server" "PONG" \ 'redis-server --port 6390 --daemonize yes --save "" >/dev/null && sleep 2 && redis-cli -p 6390 ping; redis-cli -p 6390 shutdown nosave >/dev/null 2>&1' -package nginx "nginx-light" "hello from nginx" \ +package nginx "nginx-light curl" "hello from nginx" \ 'mkdir -p ng && echo "hello from nginx" > ng/index.html && printf "daemon on; pid %s/ng/pid; error_log %s/ng/err; events {} http { access_log off; server { listen 8088; root %s/ng; } }\n" "$PWD" "$PWD" "$PWD" > ng/conf && nginx -c "$PWD/ng/conf" && sleep 1 && curl -s http://127.0.0.1:8088/; nginx -c "$PWD/ng/conf" -s stop' -# As on Ubuntu: the package's cluster, started as a service, and psql as the postgres user +package mariadb "mariadb-server" "42" \ + '(mariadbd-safe --no-watch >/dev/null 2>&1 &); for i in $(seq 1 90); do mariadb -N -e "select 1" >/dev/null 2>&1 && break; sleep 1; done; mariadb -N -e "create database t; use t; create table x(a int); insert into x values (41); select a+1 from x"; s=$?; mariadb-admin shutdown >/dev/null 2>&1; exit $s' +package postgresql-client "postgresql-client" "psql (PostgreSQL)" 'psql --version' +# As on Ubuntu: the package's cluster, started as a service, and psql as the postgres user. It installs, +# but the server refuses to run as root and Cortex's root is only pretended (pg_createcluster stops at +# "Could not change user id"), so this one is a known gap outside root mode. package postgresql "postgresql" "1" \ 'service postgresql start && sleep 5 && su postgres -s /bin/sh -c "psql -tAc \"select 1\""; s=$?; service postgresql stop >/dev/null 2>&1; exit $s' +# A name in the list that is not one of the table's: a finding of its own, not an empty run +if [ "$ONLY" != " " ]; then + for wanted in $ONLY; do + [[ "$RAN" == *" $wanted "* ]] || echo "FAIL $wanted (0 s): not a package of this run (the names are those of the table)" >> "$RESULTS" + done +fi + passed=$(grep -c '^PASS' "$RESULTS") failed=$(grep -c '^FAIL' "$RESULTS") echo "passed=$passed failed=$failed" > "$OUT/done" diff --git a/tests/e2e/in-terminal.sh b/tests/e2e/in-terminal.sh index 00a3c12..1e5b749 100644 --- a/tests/e2e/in-terminal.sh +++ b/tests/e2e/in-terminal.sh @@ -20,6 +20,18 @@ if [ -n "${CORTEX_E2E_EMULATOR:-}" ]; then arm-sve arm-sve2 arm-sveaes arm-svepmull arm-svesha3 arm-svesm4 > /etc/gcrypt/hwf.deny fi +# What the app's set-up left in the system (the profile scripts, apt and dpkg settings, users and +# groups, the wrappers in /usr/local/bin): a small archive for the local test lab, which has none of +# the app's own start-up steps. Nothing that holds a secret goes in it (no shadow files, none of +# ~/.cortex but the keys bar), as the run's output is kept as an artifact. +(cd / && tar -cf "$OUT/app-config.tar" --ignore-failed-read --exclude=etc/ssl --exclude=etc/ld.so.cache \ + etc/profile etc/profile.d etc/bash.bashrc etc/apt etc/dpkg etc/passwd etc/group \ + etc/hosts etc/resolv.conf etc/nsswitch.conf etc/environment etc/cortex etc/skel usr/local usr/sbin/policy-rc.d \ + usr/sbin/ldconfig usr/sbin/start-stop-daemon usr/sbin/mandb usr/lib/locale/C.UTF-8 usr/lib/locale/en_US.UTF-8 \ + var/lib/dpkg/status var/lib/cortex 2>/dev/null; cd "$HOME" && tar -rf "$OUT/app-config.tar" -C / --ignore-failed-read \ + "$(printf '%s' "$HOME" | sed 's#^/##')/.bashrc" "$(printf '%s' "$HOME" | sed 's#^/##')/.profile" "$(printf '%s' "$HOME" | sed 's#^/##')/.bash_profile" \ + "$(printf '%s' "$HOME" | sed 's#^/##')/.cortex/keys" "$(printf '%s' "$HOME" | sed 's#^/##')/.cortex/keys.example" 2>/dev/null) || true + passed=0 failed=0 @@ -68,6 +80,10 @@ check os-release grep -q 'VERSION_ID="24.04"' /etc/os-release expect dpkg-arch "${CORTEX_E2E_ARCH:-arm64}" dpkg --print-architecture expect id-root "uid=0(root)" id expect bash-arrays ok bash -c 'declare -A m=([a]=ok); echo "${m[a]}"' +# The system users and groups every Ubuntu has (packages' scripts use them), and sudo as a guide types it +expect base-users "man:x:6:12:" getent passwd man +expect base-groups "adm:x:4:" getent group adm +expect sudo ok sudo printf ok expect pipes 100000 bash -c 'seq 1 100000 | sort -rn | head -n 1' expect perl ok perl -e 'print "ok\n"' diff --git a/tests/e2e/run-emulator-test.sh b/tests/e2e/run-emulator-test.sh index 9ded360..8d63d1f 100644 --- a/tests/e2e/run-emulator-test.sh +++ b/tests/e2e/run-emulator-test.sh @@ -258,6 +258,16 @@ tap_center "$WIDTH" "$HEIGHT" sleep 2 TEST_COMMAND="CORTEX_E2E_EMULATOR=1 CORTEX_E2E_ARCH=$DPKG_ARCH bash /sdcard/Download/cortex-e2e.sh" [ "${E2E_NO_APT:-0}" = 1 ] && TEST_COMMAND="CORTEX_E2E_NO_APT=1 $TEST_COMMAND" +# Only some of the packages of a compatibility run (a list of names, nothing else gets through) +if [ -n "${E2E_COMPAT_ONLY:-}" ]; then + if [[ "$E2E_COMPAT_ONLY" =~ ^[A-Za-z0-9_.+,\ -]+$ ]]; then + TEST_COMMAND="CORTEX_COMPAT_ONLY='$E2E_COMPAT_ONLY' $TEST_COMMAND" + else + # (not the whole run instead of what was asked for) + log "E2E_COMPAT_ONLY has more than package names in it: '$E2E_COMPAT_ONLY'" + exit 2 + fi +fi type_line "$TEST_COMMAND" if ! wait_for 90 run_ test -d "$DEVICE_OUT/started"; then shot "03-not-started" diff --git a/tests/go/gotest/main.go b/tests/go/gotest/main.go index 7928b59..df9782f 100644 --- a/tests/go/gotest/main.go +++ b/tests/go/gotest/main.go @@ -30,11 +30,128 @@ func first(b []byte) string { return s } +// stress reads many files from many goroutines at once, each of them its own, and checks every read +// returned the file that was asked for. The paths are rewritten into a scratch area: when two calls +// shared one, a thread that was not running for a moment found another path in it and Go programs +// (go build, go list) opened the wrong files. +func stress() { + dir := "/tmp/gostress" + os.RemoveAll(dir) + if err := os.MkdirAll(dir, 0o755); err != nil { + fmt.Printf("stress=ERR %v\n", err) + return + } + const files = 200 + for i := 0; i < files; i++ { + name := fmt.Sprintf("%s/f%03d", dir, i) + if err := os.WriteFile(name, []byte(fmt.Sprintf("file %03d\n", i)), 0o644); err != nil { + fmt.Printf("stress=ERR %v\n", err) + return + } + } + const workers = 96 + const rounds = 300 + errs := make(chan string, workers) + done := make(chan bool, workers) + for w := 0; w < workers; w++ { + go func(w int) { + bad := 0 + first := "" + for r := 0; r < rounds; r++ { + i := (w*7 + r*13) % files + name := fmt.Sprintf("%s/f%03d", dir, i) + b, err := os.ReadFile(name) + if err != nil || string(b) != fmt.Sprintf("file %03d\n", i) { + bad++ + if first == "" { + first = fmt.Sprintf("%s: read %q err=%v", name, b, err) + } + } + if _, err := os.Stat(name); err != nil { + bad++ + if first == "" { + first = fmt.Sprintf("%s: stat err=%v", name, err) + } + } + } + if bad > 0 { + errs <- fmt.Sprintf("worker %d: %d wrong, first: %s", w, bad, first) + } + done <- true + }(w) + } + for w := 0; w < workers; w++ { + <-done + } + close(errs) + var wrong []string + for e := range errs { + wrong = append(wrong, e) + } + os.RemoveAll(dir) + if len(wrong) > 0 { + fmt.Printf("stress=WRONG %v\n", wrong[:1]) + return + } + fmt.Printf("stress=ok %d reads\n", workers*rounds) +} + +// execstress starts a program from more goroutines at once than the patch has slots for (64). A +// program started by a vfork child keeps the slot its path was in until the goroutine that made the +// child makes its next call: when that was not given back, the goroutines after the sixty-fourth +// waited for a slot for good. +func execstress() { + const workers = 80 + start := make(chan struct{}) + done := make(chan string, workers) + for w := 0; w < workers; w++ { + go func(w int) { + <-start + out, err := exec.Command("/usr/bin/hello", fmt.Sprint(w)).Output() + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 3 { + err = nil // hello exits with 3 + } + if err != nil || !strings.HasPrefix(string(out), "hello argc=2") { + done <- fmt.Sprintf("worker %d: %q %v", w, out, err) + return + } + done <- "" + }(w) + } + close(start) + var wrong []string + timeout := time.After(100 * time.Second) + for w := 0; w < workers; w++ { + select { + case e := <-done: + if e != "" { + wrong = append(wrong, e) + } + case <-timeout: + fmt.Printf("execstress=HANG after %d of %d programs\n", w, workers) + return + } + } + if len(wrong) > 0 { + fmt.Printf("execstress=WRONG %v\n", wrong[0]) + return + } + fmt.Printf("execstress=ok %d programs\n", workers) +} + func main() { + if len(os.Args) > 1 && os.Args[1] == "execstress" { + execstress() + return + } if len(os.Args) > 1 && os.Args[1] == "ubuntu" { ubuntu() return } + if len(os.Args) > 1 && os.Args[1] == "stress" { + stress() + return + } b, err := os.ReadFile("/etc/os-release") show("os-release", first(b), err) diff --git a/tests/native/test_cortex_native.c b/tests/native/test_cortex_native.c index 360dd8e..c30b159 100644 --- a/tests/native/test_cortex_native.c +++ b/tests/native/test_cortex_native.c @@ -1763,6 +1763,35 @@ static void test_path_helpers(void) { append_rootfs_lib_dirs(with_sep, sizeof(with_sep), 1, 1); ASSERT_TRUE(strncmp(with_sep, "x:/data", 7) == 0, "separate=1 starts with ':'"); + /* the caller's list that is this list already adds nothing, a part of it only what it lacks */ + char merged[4096]; + size_t used = (size_t)snprintf(merged, sizeof(merged), "%s", dirs); + size_t merged_len = append_rootfs_lib_dirs_missing_from(merged, sizeof(merged), used, 1, dirs); + ASSERT_EQ(merged_len, used, "the same list adds no directory"); + ASSERT_STR_EQ(merged, dirs, "and leaves the list as it was"); + const char *root = "/data/data/org.cortex.terminal/files/cortex"; + char caller[512]; + snprintf(caller, sizeof(caller), "%s/usr/lib/aarch64-linux-gnu:/opt/own/lib:%s/lib", root, root); + used = (size_t)snprintf(merged, sizeof(merged), "%s", caller); + append_rootfs_lib_dirs_missing_from(merged, sizeof(merged), used, 1, caller); + char expect_first[512]; + snprintf(expect_first, sizeof(expect_first), "%s:%s/lib/aarch64-linux-gnu:", caller, root); + ASSERT_TRUE(strncmp(merged, expect_first, strlen(expect_first)) == 0, + "the caller's directories stay first, the missing ones follow in the usual order"); + entries = 1; + for (const char *p = merged; *p; p++) entries += (*p == ':'); + ASSERT_EQ(entries, 13 + 1, "13 directories of the rootfs, and the caller's own one"); + char only_root[4096]; + append_rootfs_lib_dirs_missing_from(only_root, sizeof(only_root), 0, 0, ""); + ASSERT_STR_EQ(only_root, dirs, "no list of the caller's adds all of them, without a leading ':'"); + char prefix_only[256]; + snprintf(prefix_only, sizeof(prefix_only), "%s/lib/aarch64-linux-gnu-extra", root); + used = (size_t)snprintf(merged, sizeof(merged), "%s", prefix_only); + append_rootfs_lib_dirs_missing_from(merged, sizeof(merged), used, 1, prefix_only); + entries = 1; + for (const char *p = merged; *p; p++) entries += (*p == ':'); + ASSERT_EQ(entries, 13 + 1, "a directory whose name only begins like one of the list is not that one"); + /* SELinux label filtering of listxattr() results */ char list1[] = "user.a\0security.selinux\0user.b"; ssize_t n1 = drop_selinux_xattr(list1, (ssize_t)sizeof(list1)); diff --git a/tests/scripts/test_go_programs.sh b/tests/scripts/test_go_programs.sh index 1b28045..e7f4395 100755 --- a/tests/scripts/test_go_programs.sh +++ b/tests/scripts/test_go_programs.sh @@ -68,7 +68,7 @@ check() { # name, then a command that succeeds when the check does echo "== building" "$here/scripts/build-gosys.sh" || { echo "the added code did not build"; exit 1; } hook=$work/libcortex-hook.so -aarch64-linux-gnu-gcc -std=gnu11 -shared -fPIC -O2 -w -Wl,-z,max-page-size=16384 -o "$hook" "$here/app/src/main/cpp/cortex-hook.c" -ldl \ +aarch64-linux-gnu-gcc -std=gnu11 -shared -fPIC -O2 -fno-delete-null-pointer-checks -w -Wl,-z,max-page-size=16384 -o "$hook" "$here/app/src/main/cpp/cortex-hook.c" -ldl \ || { echo "the hook did not build"; exit 1; } gcc -std=gnu11 -O1 -w -o "$work/gopatch" "$here/tests/go/gopatch.c" -ldl -lpthread || { echo "gopatch did not build"; exit 1; } @@ -189,6 +189,19 @@ once=$(hash_of "$work/patched") "$work/gopatch" "$root" "$work/patched" >/dev/null check "a second patch changes nothing" test "$(hash_of "$work/patched")" = "$once" +# Ninety-six goroutines read two hundred files, each its own. The scratch memory for the rewritten +# paths was shared by sixteen slots in turn, and a thread that had not run for a moment found +# another call's path in its slot: `go build` opened other files than it asked for. +stress_out=$(run_in_root "$work/patched" stress | tail -n 1) +check "many calls at once each get the file they asked for" bash -c "[[ '$stress_out' == 'stress=ok'* ]]" +[[ "$stress_out" == 'stress=ok'* ]] || echo " $stress_out" + +# More goroutines than slots start a program at once: the slot of a path that a vfork child passed to +# a successful execve is given back when its goroutine goes on, so that all of them finish +exec_out=$(run_in_root "$work/patched" execstress | tail -n 1) +check "more programs started at once than there are slots all start" bash -c "[[ '$exec_out' == 'execstress=ok'* ]]" +[[ "$exec_out" == 'execstress=ok'* ]] || echo " $exec_out" + # CORTEX_NO_GOSYS=1: the program as it was built, and the code comes back with the next start CORTEX_NO_GOSYS=1 "$work/gopatch" "$root" "$work/patched" >/dev/null check "CORTEX_NO_GOSYS takes the code out again, exactly" test "$(hash_of "$work/patched")" = "$before" diff --git a/tests/scripts/test_hook_qemu.sh b/tests/scripts/test_hook_qemu.sh new file mode 100755 index 0000000..3c1797a --- /dev/null +++ b/tests/scripts/test_hook_qemu.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# Small C programs run under qemu-user with the AArch64 hook loaded, for what a mistake in the hook +# did to real programs before: each check is the call that crashed or failed in one of them. +# +# tests/scripts/test_hook_qemu.sh +# +# Needs aarch64-linux-gnu-gcc (with its libraries in /usr/aarch64-linux-gnu) and qemu-aarch64. Without +# them the test says so and passes, unless CORTEX_REQUIRE_QEMU_TESTS=1. +set -u +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +skip() { + echo "skip: $1" + [ "${CORTEX_REQUIRE_QEMU_TESTS:-0}" = 1 ] && exit 1 + exit 0 +} +for tool in aarch64-linux-gnu-gcc qemu-aarch64; do + command -v "$tool" >/dev/null 2>&1 || skip "$tool is not installed" +done +cross=/usr/aarch64-linux-gnu/lib +[ -e "$cross/ld-linux-aarch64.so.1" ] || skip "the AArch64 C library is not in $cross" + +work=$(mktemp -d "${TMPDIR:-/tmp}/cortex-hook-qemu.XXXXXX") +trap 'rm -rf "$work"' EXIT +passed=0 +failed=0 +ok() { passed=$((passed + 1)); echo " ok $1"; } +bad() { failed=$((failed + 1)); echo " FAIL $1"; } +check() { # name, then a command that succeeds when the check does + local name=$1 + shift + if "$@"; then ok "$name"; else bad "$name"; fi +} + +echo "== building" +# Without -fno-delete-null-pointer-checks: the hook is meant to be right without that flag too +[ -e "$here/app/src/main/cpp/gosys/gosys_blob.h" ] || "$here/scripts/build-gosys.sh" >/dev/null 2>&1 +aarch64-linux-gnu-gcc -std=gnu11 -shared -fPIC -O3 -w -Wl,-z,max-page-size=16384 -o "$work/hook.so" \ + "$here/app/src/main/cpp/cortex-hook.c" -ldl || { echo "the hook did not build"; exit 1; } +mkdir -p "$work/root/tmp" "$work/root/etc" "$work/root/lib/aarch64-linux-gnu" +# The C library inside the rootfs too, for the programs that start others (the hook maps absolute paths into it) +for f in ld-linux-aarch64.so.1 libc.so.6 libm.so.6 libgcc_s.so.1; do cp -L "$cross/$f" "$work/root/lib/aarch64-linux-gnu/" 2>/dev/null; done + +# Runs a program with the hook preloaded, as ld.so does for Ubuntu's programs in Cortex +run() { + (cd "$work" && 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" "$@" 2>&1 | grep -v "cannot be preloaded") +} +prog() { # name, source on stdin + aarch64-linux-gnu-gcc -std=gnu11 -O2 -w -o "$work/$1" -x c - || { echo "$1 did not build"; exit 1; } +} + +echo "== programs" +# Rust's standard library asks whether statx exists by calling it with a NULL path and a NULL buffer +# (EFAULT: it does). glibc's header marks both nonnull, so the compiler removed the hook's NULL +# check and every Rust program (ripgrep, rustc, cargo...) crashed at its first file access. +prog statx_probe <<'EOC' +#define _GNU_SOURCE +#include +#include +#include +#include +int main(void) { + errno = 0; + int r = statx(0, NULL, 0, STATX_ALL, NULL); + printf("statx(NULL, NULL) = %d, %s\n", r, errno == EFAULT ? "EFAULT" : errno == ENOSYS ? "ENOSYS" : "other"); + return 0; +} +EOC +out=$(run "$work/statx_probe") +check "statx with a NULL path and buffer answers EFAULT, as Rust's probe expects" test "$out" = "statx(NULL, NULL) = -1, EFAULT" +[ "$out" = "statx(NULL, NULL) = -1, EFAULT" ] || echo " got: $out" + +# Ubuntu does not use SELinux: libselinux found Android's and pam_rootok then refused root, so chfn +# and `adduser --gecos` (the installation of PostgreSQL) failed +prog selinux <<'EOC' +#include +extern int is_selinux_enabled(void) __attribute__((weak)); +int main(void) { + printf("is_selinux_enabled=%d\n", is_selinux_enabled ? is_selinux_enabled() : -1); + return 0; +} +EOC +out=$(run "$work/selinux") +check "SELinux is off for Ubuntu's programs" test "$out" = "is_selinux_enabled=0" +[ "$out" = "is_selinux_enabled=0" ] || echo " got: $out" + +# The shadow database comes from the rootfs: libc reads the machine's own /etc/shadow, which Android +# does not have, and pam_unix's account check for root failed with "Authentication failure" +printf 'cortexuser:$6$salt$hash:19000:1:99999:7:::\ncortexlock:!:19001::::::\n' > "$work/root/etc/shadow" +prog shadow <<'EOC' +#define _GNU_SOURCE +#include +#include +#include +int main(void) { + struct spwd *e = getspnam("cortexuser"); + if (e) printf("getspnam: %s %s last=%ld min=%ld max=%ld warn=%ld inact=%ld\n", e->sp_namp, e->sp_pwdp, e->sp_lstchg, e->sp_min, e->sp_max, e->sp_warn, e->sp_inact); + else printf("getspnam: none (%d)\n", errno); + struct spwd buf, *res = NULL; + char raw[512]; + int rc = getspnam_r("cortexlock", &buf, raw, sizeof(raw), &res); + printf("getspnam_r: rc=%d %s %s last=%ld min=%ld\n", rc, res ? res->sp_namp : "none", res ? res->sp_pwdp : "-", res ? res->sp_lstchg : 0, res ? res->sp_min : 0); + printf("unknown: %s\n", getspnam("nosuchuser-cortex") ? "found" : "none"); + return 0; +} +EOC +out=$(run "$work/shadow") +expected_shadow=$'getspnam: cortexuser $6$salt$hash last=19000 min=1 max=99999 warn=7 inact=-1\ngetspnam_r: rc=0 cortexlock ! last=19001 min=-1\nunknown: none' +check "getspnam reads the shadow file of the rootfs" test "$out" = "$expected_shadow" +[ "$out" = "$expected_shadow" ] || printf ' got:\n%s\n' "$out" + +# The ARM translator's memory files (ndk_translation keeps one at descriptor 3) survive close(), +# closefrom() and close_range(): the hook finds them at its start. Here a program of the test makes a +# memory file named like the translator's, puts it at descriptor 3 and starts the next one. +prog kept_child <<'EOC' +#define _GNU_SOURCE +#include +#include +#include +#include +int main(void) { + close(3); + syscall(SYS_close_range, 3u, ~0u, 0); + printf("descriptor 3 after close(): %s\n", fcntl(3, F_GETFD) >= 0 ? "kept" : "gone"); + return 0; +} +EOC +prog kept_parent <<'EOC' +#define _GNU_SOURCE +#include +#include +#include +int main(int argc, char **argv) { + // This program started without a translator's file (the memory file is made below), so the hook + // would tell the next one that there is none; as on a machine that has a translator, it is not + putenv("CORTEX_NT=1"); + int fd = memfd_create("child", 0); + if (fd != 3) { + if (dup2(fd, 3) != 3) return 2; + close(fd); + } + char *args[] = { argv[1], "--preload", argv[2], "--library-path", argv[3], argv[4], NULL }; + execv(argv[1], args); + return 3; +} +EOC +out=$(run "$work/kept_parent" "$work/root/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" "$work/hook.so" "$work/root/lib/aarch64-linux-gnu" "$work/kept_child") +check "a descriptor that is the translator's memory file stays open" test "$out" = "descriptor 3 after close(): kept" +[ "$out" = "descriptor 3 after close(): kept" ] || echo " got: $out" + +# A program that found no translator's files tells the ones it starts, which then do not look (the +# look is a system call to /proc/self/fd in every program that is started) +prog nt_child <<'EOC' +#include +#include +int main(void) { + const char *v = getenv("CORTEX_NT"); + printf("CORTEX_NT=%s\n", v ? v : "unset"); + return 0; +} +EOC +prog nt_parent <<'EOC' +#include +#include +int main(int argc, char **argv) { + if (argc > 1) putenv(argv[1]); + char *args[] = { "nt_child", NULL }; + execv("/bin/nt_child", args); + return 3; +} +EOC +mkdir -p "$work/root/bin" && cp "$work/nt_child" "$work/root/bin/nt_child" +out=$(run "$work/nt_parent") +check "a program started by one that found no translator is told so" test "$out" = "CORTEX_NT=0" +[ "$out" = "CORTEX_NT=0" ] || echo " got: $out" +out=$(run "$work/nt_parent" CORTEX_NT=1) +check "a value of its own is left as it is" test "$out" = "CORTEX_NT=1" +[ "$out" = "CORTEX_NT=1" ] || echo " got: $out" + +# The descriptors of an ordinary program are closed as asked +prog plain_child <<'EOC' +#include +#include +#include +int main(void) { + int fd = open("/dev/null", O_RDONLY); + close(fd); + printf("descriptor %d after close(): %s\n", fd, fcntl(fd, F_GETFD) >= 0 ? "open" : "closed"); + return 0; +} +EOC +out=$(run "$work/plain_child") +check "other descriptors are closed as asked" bash -c "[[ '$out' == *'after close(): closed' ]]" + +echo +echo "$passed passed, $failed failed" +[ "$failed" = 0 ] diff --git a/tests/scripts/test_shell_scripts.sh b/tests/scripts/test_shell_scripts.sh index dd3562c..88934f0 100644 --- a/tests/scripts/test_shell_scripts.sh +++ b/tests/scripts/test_shell_scripts.sh @@ -290,6 +290,35 @@ if [ ! -d /system/bin ]; then [ "$OUT" = "ok" ] && pass "root mode is recognised without CORTEX_CHROOT (no CORTEX_ROOT, no /system)" || fail "root mode detection: got '$OUT'" fi +# Normal mode on a phone without root (no su to ask, or one that does not give root): sudo and su run +# what they are given in Cortex's own root, instead of failing with "root not found" +nomode() { env -u CORTEX_CHROOT -u CORTEX_HOST_SU CORTEX_ROOT="$SU_TEST_DIR" PATH="$ROOTMODE_STUBS:$PATH" SHELL="$BASH_EXEC" "$@"; } +OUT="$(nomode CORTEX_INVOKED_AS=sudo bash "$SU_SH" printf '%s|%s' "arg with spaces" "arg'quote" 2>&1)" +[ "$OUT" = "arg with spaces|arg'quote" ] && pass "no root: sudo runs the command with its arguments" || fail "no root: sudo command: got '$OUT'" +OUT="$(nomode CORTEX_INVOKED_AS=sudo bash "$SU_SH" -E -n -- printf ok 2>&1)" +[ "$OUT" = "ok" ] && pass "no root: sudo ignores its options" || fail "no root: sudo options: got '$OUT'" +OUT="$(nomode CORTEX_INVOKED_AS=sudo bash "$SU_SH" -u postgres psql -l 2>&1)" +[ "$OUT" = "runuser -u postgres -- psql -l" ] && pass "no root: sudo -u USER uses runuser" || fail "no root: sudo -u: got '$OUT'" +OUT="$(nomode CORTEX_INVOKED_AS=su bash "$SU_SH" -c 'printf "%s|%s" "$1" "$2"' _ one 'two words' 2>&1)" +[ "$OUT" = "one|two words" ] && pass "no root: su -c runs the command with the shell's arguments" || fail "no root: su -c: got '$OUT'" +OUT="$(nomode CORTEX_INVOKED_AS=su bash "$SU_SH" root -c 'printf ok' 2>&1)" +[ "$OUT" = "ok" ] && pass "no root: su root -c runs the command" || fail "no root: su root -c: got '$OUT'" +OUT="$(nomode CORTEX_INVOKED_AS=su bash "$SU_SH" - postgres 2>&1)" +[ "$OUT" = "runuser -l postgres" ] && pass "no root: su - USER uses runuser -l" || fail "no root: su - USER: got '$OUT'" +OUT="$(nomode CORTEX_INVOKED_AS=su bash "$SU_SH" -s "$BASH_EXEC" postgres -c 'psql -l' 2>&1)" +[ "$OUT" = "runuser -u postgres -- $BASH_EXEC -c psql -l" ] && pass "no root: su USER -c CMD uses runuser -u, and the shell of -s" || fail "no root: su USER -c: got '$OUT'" +# Without -s the shell is the user's own (here nobody's, wherever the system keeps nologin), not the caller's +NOBODY_SHELL="$(getent passwd nobody | cut -d: -f7)" +if [ -n "$NOBODY_SHELL" ]; then + OUT="$(nomode CORTEX_INVOKED_AS=su bash "$SU_SH" nobody -c true 2>&1)" + [ "$OUT" = "runuser -u nobody -- $NOBODY_SHELL -c true" ] && pass "no root: su USER runs the shell USER has" || fail "no root: su USER shell: got '$OUT'" +fi +NOROOT_SU="$TEST_TMP/mock_su_no_root" +printf '#!%s\nexit 1\n' "$BASH_EXEC" > "$NOROOT_SU" +chmod +x "$NOROOT_SU" +OUT="$(env -u CORTEX_CHROOT CORTEX_HOST_SU="$NOROOT_SU" CORTEX_ROOT="$SU_TEST_DIR" CORTEX_INVOKED_AS=sudo bash "$SU_SH" printf ok 2>&1)" +[ "$OUT" = "ok" ] && pass "a su that does not give root: sudo runs the command in Cortex's root" || fail "su without root: got '$OUT'" + TSU_DIR="$TEST_TMP/tsu-dir" mkdir -p "$TSU_DIR" printf '#!%s\necho "as=${CORTEX_INVOKED_AS:-none} $*"\n' "$BASH_EXEC" > "$TSU_DIR/su"