session.d.txttxt · 263 linesall
/**
What the Android app runs, decided as data (docs/specs/terminal/android.md,
`D5`): the session configuration the APK carries, the paths under the app's
data dir, the program and its environment. Pure — the platform half is
`android_app.d` — so every rule here is tested on the host.
*/
module session;

import std.path : buildPath;

/// The session modes (`NOD4`, `NOD5`–`NOD7`).
enum SessionMode
{
    shell, /// `/system/bin/sh` as a login shell
    bootstrap, /// nix-on-droid: install the bootstrap once, then run `login`
}

/// The APK's `session.conf` asset (`KEY=VALUE` lines), parsed.
struct SessionConfig
{
    SessionMode mode = SessionMode.shell;
    /// The bootstrap base URL offered as the installer's default; the zip is
    /// `<url>/bootstrap-<arch>.zip`.
    string bootstrapUrl;
}

/**
Parse `session.conf`. Unknown keys are ignored (a newer builder's options on
an older app), an unknown mode is the plain shell — the app must start, and a
shell is the safe thing to start.
*/
SessionConfig parseSessionConfig(const(char)[] text) @safe pure
{
    import sparkles.android.bundle : parseEnvFile;

    SessionConfig c;
    foreach (pair; parseEnvFile(text))
    {
        switch (pair.key)
        {
            case "mode":
                c.mode = pair.value == "bootstrap" ? SessionMode.bootstrap
                    : SessionMode.shell;
                break;
            case "bootstrapUrl":
                c.bootstrapUrl = pair.value;
                break;
            default:
                break;
        }
    }
    return c;
}

///
@("session.parseSessionConfig")
@safe pure unittest
{
    const c = parseSessionConfig(
        "# written by mkTerminalApk\nmode=bootstrap\n" ~
        "bootstrapUrl=https://example.org/bootstrap\nfuture=1\n");
    assert(c.mode == SessionMode.bootstrap);
    assert(c.bootstrapUrl == "https://example.org/bootstrap");

    assert(parseSessionConfig("").mode == SessionMode.shell);
    assert(parseSessionConfig("mode=unheard-of").mode == SessionMode.shell);
}

/// The directories under the app's data dir (`/data/user/0/<pkg>/files`),
/// laid out as Termux and nix-on-droid's bootstrap expect.
struct SessionPaths
{
    string files;

    string home() const @safe pure nothrow => buildPath(files, "home");
    string prefix() const @safe pure nothrow => buildPath(files, "usr");
    string staging() const @safe pure nothrow => buildPath(files, "usr-staging");
    string tmp() const @safe pure nothrow => buildPath(files, "usr", "tmp");
    string login() const @safe pure nothrow => buildPath(files, "usr", "bin", "login");
    /// `~/.termux` — the appearance and extra-keys files (`NOD10`, `NOD11`).
    string termuxDir() const @safe pure nothrow => buildPath(home, ".termux");
    /// The plain shell's own commands, ahead of `/system/bin` on its `PATH`
    /// ($(LREF shellTools)).
    string shellBin() const @safe pure nothrow => buildPath(files, "bin");
    /// The on-device test oracle's directory (`NOD14`).
    string debugDir() const @safe pure nothrow => buildPath(files, ".debug");
    /// The app's package name: the data dir is `…/<package>/files`.
    string packageName() const @safe pure nothrow
    {
        import std.path : baseName, dirName;

        return files.dirName.baseName;
    }
}

///
@("session.SessionPaths")
@safe pure unittest
{
    const p = SessionPaths("/data/user/0/dev.sparkles.nix/files");
    assert(p.home == "/data/user/0/dev.sparkles.nix/files/home");
    assert(p.prefix == "/data/user/0/dev.sparkles.nix/files/usr");
    assert(p.login == "/data/user/0/dev.sparkles.nix/files/usr/bin/login");
    assert(p.termuxDir == "/data/user/0/dev.sparkles.nix/files/home/.termux");
    assert(p.packageName == "dev.sparkles.nix");
}

/**
The child's environment (`NOD7`): the app's own environment — which carries
the Android system variables (`ANDROID_*`, `BOOTCLASSPATH`,
`EXTERNAL_STORAGE`, …) the zygote gave it — minus what must not leak into a
session, plus the session's own.

Removed: the dynamic-loader overrides (`LD_LIBRARY_PATH` and `LD_PRELOAD`
would redirect every binary in the prefix) and anything the session sets
itself. `prefixed` selects the bootstrap layout: `PREFIX` set and `PATH` the
prefix's `bin` alone. Entries keep the parent's order, the session's
follow; the result is `KEY=VALUE` strings.
*/
string[] sessionEnvironment(scope const string[] parent, const SessionPaths p,
    bool prefixed) @safe pure
{
    import std.algorithm.searching : canFind, startsWith;
    import std.string : indexOf;

    string[] own = [
        "HOME=" ~ p.home,
        "TMPDIR=" ~ (prefixed ? p.tmp : buildPath(p.files, "tmp")),
        // Termux's: the prefix alone. nix-on-droid's `login` calls Android's
        // tools by absolute path, and under proot the host root stays
        // visible, so `/system/bin` here would shadow the environment's own
        // commands with Android's (`am`, `ls`, ...) wherever Nix has none.
        "PATH=" ~ (prefixed ? buildPath(p.prefix, "bin") : p.shellBin ~ ":/system/bin"),
        "LANG=en_US.UTF-8",
        "TERM=xterm-256color",
        "COLORTERM=truecolor",
    ];
    if (prefixed)
    {
        own ~= "PREFIX=" ~ p.prefix;
        // Which app this is — as a Termux-based app says it. nix-on-droid
        // defaults `build.androidAppId` to it, so a configuration that does
        // not name the app still builds its paths for this one.
        own ~= "TERMUX_APP__PACKAGE_NAME=" ~ p.packageName;
    }

    static immutable dropped = ["LD_LIBRARY_PATH", "LD_PRELOAD"];
    string[] result;
    foreach (entry; parent)
    {
        const eq = entry.indexOf('=');
        if (eq <= 0)
            continue;
        const key = entry[0 .. eq];
        if (dropped.canFind(key))
            continue;
        bool overridden = false;
        foreach (o; own)
            if (o.startsWith(key) && o.length > key.length && o[key.length] == '=')
                overridden = true;
        if (!overridden)
            result ~= entry;
    }
    return result ~ own;
}

///
@("session.sessionEnvironment")
@safe pure unittest
{
    import std.algorithm.searching : canFind;

    const p = SessionPaths("/data/org.example/files");
    const parent = [
        "ANDROID_ROOT=/system", "HOME=/", "LD_PRELOAD=libsigchain.so",
        "PATH=/sbin:/system/bin", "BOOTCLASSPATH=/apex/x.jar", "odd",
    ];

    const boot = sessionEnvironment(parent, p, true);
    assert(boot.canFind("ANDROID_ROOT=/system"), "system variables pass through");
    assert(boot.canFind("BOOTCLASSPATH=/apex/x.jar"));
    assert(!boot.canFind("LD_PRELOAD=libsigchain.so"), "loader overrides never leak");
    assert(!boot.canFind("HOME=/") && boot.canFind("HOME=/data/org.example/files/home"),
        "the session's own values replace the parent's");
    assert(boot.canFind("PREFIX=/data/org.example/files/usr"));
    assert(boot.canFind("PATH=/data/org.example/files/usr/bin"), "the prefix alone, as Termux sets it");
    assert(boot.canFind("TMPDIR=/data/org.example/files/usr/tmp"));
    assert(boot.canFind("TERMUX_APP__PACKAGE_NAME=org.example"));
    assert(!boot.canFind("odd"), "an entry without `=` is not an entry");

    const shell = sessionEnvironment(parent, p, false);
    assert(shell.canFind("PATH=/data/org.example/files/bin:/system/bin"),
        "the app's own commands first (shellTools)");
    assert(shell.canFind("TMPDIR=/data/org.example/files/tmp"));
    assert(!shell.canFind!(e => e.length >= 7 && e[0 .. 7] == "PREFIX="));
}

/// A command the plain shell gets from the app rather than from Android.
struct ShellTool
{
    string name; /// the file name in $(LREF SessionPaths.shellBin)
    string script; /// its contents, a `/system/bin/sh` script
}

/**
The plain shell's own commands, written to `SessionPaths.shellBin` when the
session starts.

`clear`: Android's is toybox's, which sends only `ESC[2J ESC[H` — erase the
screen, home the cursor — and so leaves the scrollback, as any terminal keeps
it on `2J`. The `clear` of every Linux system (ncurses, for
`TERM=xterm-256color`) also sends `ESC[3J`, which erases the scrollback, and
so does this one. The bootstrap session needs none of this: its `clear` is
ncurses'.
*/
immutable ShellTool[] shellTools = [
    ShellTool("clear",
        "#!/system/bin/sh\n"
        ~ "# Written by the terminal app: Android's clear keeps the scrollback.\n"
        ~ "printf '\\033[H\\033[2J\\033[3J'\n"),
];

///
@("session.shellTools")
@safe pure unittest
{
    import std.algorithm.searching : canFind;

    assert(shellTools[0].name == "clear");
    assert(shellTools[0].script.canFind(`\033[3J`), "clear erases the scrollback too");
}

/// The bootstrap zip's name for the running ABI (`NOD5`) — nix-on-droid's
/// `bootstrap-<arch>.zip`, where `arch` is the Nix system's CPU.
string bootstrapArch() @safe pure nothrow
{
    version (AArch64)
        return "aarch64";
    else version (X86_64)
        return "x86_64";
    else version (ARM)
        return "arm";
    else version (X86)
        return "i686";
    else
        static assert(0, "no nix-on-droid bootstrap for this CPU");
}

/// `<base>/bootstrap-<arch>.zip`, tolerating a trailing slash on `base`.
string bootstrapZipUrl(string base, string arch) @safe pure nothrow
{
    while (base.length && base[$ - 1] == '/')
        base = base[0 .. $ - 1];
    return base ~ "/bootstrap-" ~ arch ~ ".zip";
}

///
@("session.bootstrapZipUrl")
@safe pure nothrow unittest
{
    assert(bootstrapZipUrl("https://h/boot", "aarch64") == "https://h/boot/bootstrap-aarch64.zip");
    assert(bootstrapZipUrl("https://h/boot//", "x86_64") == "https://h/boot/bootstrap-x86_64.zip");
}