bunproot
v0.6.11
Published
A port of PRoot to Bun: a userspace chroot, mount --bind and binfmt_misc for Android, with no privileges required
Maintainers
Readme
bunproot
A port of PRoot to Bun/JavaScript: a
userspace chroot, mount --bind and shebang loader for Android, needing no
root and no kernel support beyond ptrace.
It runs wherever bunx does — Termux, a shell inside an app built with
minapk
(npm), or any other Android
environment with Bun on PATH. The tracer itself is Bun; the guest is an ARM64
Linux rootfs you supply. It can be an empty directory
that borrows Android's system files, or a self-contained rootfs assembled from
as few as five files.
This is early work, and it is not trying to replace Termux's PRoot. That one is mature, complete and considerably faster; if you are in Termux and it works for you, keep using it. What this port is for is the case after Termux: a rootfs runner whose only dependency is a Bun binary, so an Android environment that never had a package manager — an app built by minapk, a device where Termux is not an option — still has a way out to a Linux userspace. Bun is the one thing such an environment can be given as a single file, so it is the one thing this depends on.
Upstream PRoot and bunproot emulate hard links in different, normally incompatible link2symlink formats:
/.l2sand/.proot.l2s. Do not assume a rootfs written by one can be used directly by the other. The difference is deliberate: upstream records host paths, so moving or renaming its rootfs breaks those links; bunproot records guest paths so its rootfs remains portable. Inspect a rootfs without entering it:bunproot --l2s-status ./that-rootfsFor format details, compatibility and recovery tools, read link2symlink.md or render it in the terminal with
bunproot --l2s-docs.
Licensed GPL-2.0-or-later, inherited as a derivative work of PRoot.
NOTICE.md records what it was ported from and how to read the
src/... citations in the comments; COPYING is the licence.
Confirmed running (mostly under Alpine)
- Firefox
- Chromium
- Run with
bunproot -b "$TMPDIR:/dev/shm" -S "$ROOTFS" /bin/sh -c 'chromium --no-sandbox'.
- Run with
- bubblewrap (
bwrap) - Flatpak (limited testing)
- GNOME Text Editor
- GNOME Calculator
- PostgreSQL
- GTK3/WebKit 4.1 test browser
- LibreOffice
- bunmsh: Bun Modern Shell
- jsmdcui: Text Editor & App runtime
- npm-installed Bun
- Git clone
- OpenSSH client (
terminal.shopconfirmed) and non-root server- The server may need a port above 1024, the same account for
sshdand the login, and relaxedStrictModes. Rootsshdstill needschrootemulation.
- The server may need a port above 1024, the same account for
Quick start
Below,
bunprootmeans your route's spelling:bunx bunproot,npx bunproot,bun ./proot.js, or an installedbunproot.bunproot needs a Bun built for Android/bionic, and how you get one depends on where you are. Any build will do; prefer 1.4.1 or newer.
To locate the installed entry point (including an install managed by bunx),
bunx bunproot --print-path prints only the absolute path to proot.js and
exits. For example, js-udocker can use it directly:
export JS_UDOCKER_BUNPROOT="$(bunx bunproot --print-path)"Route 1: Termux, from the TUR repository
Termux packages Bun in TUR,
which is the shortest path — bunx comes with it:
pkg install tur-repo
pkg install bun
bunx bunproot --helpRoute 2: an app built with minapk
An app built with minapk has no npm and
no npx, so the Bun that runs bunproot is whichever one the APK was built with.
Where that Bun came from does not matter — the Termux TUR build, the official
binary, or the official binary under the LD_PRELOAD shim posted in
oven-sh/bun#39060, which turns
the blocked openat2 and fchmodat2 into ENOSYS.
bunx bunproot --helpThat succeeds on the first run, and it does so while
oven-sh/bun#39084 is still open.
minapk's bunx is really bun i -g bunproot followed by running the installed
entry point with bun directly, so it never reaches the bin-linking step whose
openat2 Android answers with SIGSYS. bunproot helps from its side by keeping
its own entry point at the package root, where the containment check that calls
openat2 is not reached for its bin target — see the comment at the top of
proot.js. minapk's documentation covers the build side.
Route 3: an Android shell with npm
Where Node is already present — some terminal apps ship it — Bun installs through npm:
npm install -g bun
npx bunproot --helpUse npx for that first run. An official Bun installed this way also has
bunx, but its first bunx bunproot is killed by SIGSYS as it finishes
installing — oven-sh/bun#39084
again — printing no message of its own. The package is already extracted by
then, so a second run succeeds and keeps succeeding. npx never goes through
that path, and neither does the TUR build's bunx.
Every route leaves you with the same bunproot command. On platforms other
than Android, follow the official Bun installation
guide — though the tracer targets ARM64
Android and does not yet run anywhere else.
Route 4: from source
git clone https://github.com/jjtseng93/bunproot.git
cd bunproot
bun proot.js --help # or: sh proot --helpproot.js is the entry npm links as bunproot; proot is a shell launcher
for a device with no Bun on PATH, which falls back to a bun-android binary
you place beside it.
Get a rootfs
bunproot --download-alpine # fetches and checksums a minirootfs
mkdir alpine && cd alpine && tar -xzvf ../alpine-minirootfs-*.tar.gz && cd ..It prints the URL and the expected SHA-256 and asks before downloading, and
verifies what it got. From a source checkout the same script is
bun tools/download-alpine.mjs.
Any ARM64 Linux rootfs works; Alpine is simply what this port is tested against.
First run
bunproot -S ./alpine /bin/sh -c 'cat /etc/os-release'A rootfs can be zero files
Android already has a shell and the usual command-line tools under /system.
An empty directory can therefore be used as a borrowed root:
mkdir empty
bunproot --android-container ./emptyFrom the shell inside the container, Bun can fetch an interactive bunmsh shell or use bunproot's Git-compatible wrapper around the pure-JavaScript isomorphic-git to work with a repository without a system Git:
bun x bunmsh
bun x bunproot --git clone https://github.com/jjtseng93/jsmdcuiOn older Android releases, the first bun x invocation may finish installing
the package and then fail with an invalid pointer error before its command
starts. The installation has normally completed despite that message; ignore
it and run the same command again, and it may start successfully on the second
run.
On a device where the invalid pointer failure persists, replace bun x xxx
with an explicit global install followed by a direct launch:
bun i -g xxx
bun /root/.bun/bin/xxxThere is one further limitation on affected Bun versions: when a package's
declared bin entry is not at the top level of that package, Android's blocked
openat2 can prevent the global bin link from being created. If
/root/.bun/bin/xxx is missing, invoke the installed JavaScript entry point
directly instead (replace the final xxx.js with that package's actual entry
file):
bun /root/.bun/install/global/node_modules/xxx/xxx.jsThis binds /system, /apex, /linkerconfig/ld.config.txt, and
/system/bin/sh at /bin/sh. The Android Bun that started bunproot is bound
at both /bin/bun and /bin/node. It prepends /system/bin to the guest
PATH and runs /system/bin/sh when no command is given. A command and more
options can be supplied normally:
bunproot --android-container ./empty /system/bin/id
bunproot --android-container ./empty /bin/sh -c 'echo hello'
bunproot --android-container ./empty /bin/node -e 'console.log(process.version)'
bunproot --android-container ./empty -b ./other-bun:/bin/bun /bin/bun app.tsWith Bun already present, this zero-file container can also clone a Git repository without a system Git.
Bindings written by the caller come after the preset, so bindings at
/bin/sh, /bin/bun, or /bin/node replace the corresponding defaults;
binding another existing file such as /dev/null at one of those paths
disables that alias. As with other bindings, this makes endpoints reachable
but does not populate the empty root directory: ls / remains empty because
the glue filesystem which synthesizes intermediate directories has not been
ported.
A rootfs can be five files
A distribution is the convenient guest, not the required one. The guest needs an ELF the loader can map and whatever that ELF asks for — nothing else. For Bun itself that is five files:
/bin/bun
/lib/ld-musl-aarch64.so.1 musl's loader, which is also its libc
/lib/libstdc++.so.6
/lib/libgcc_s.so.1 libstdc++'s own dependency, which `readelf -d`
does not list against bun
/etc/resolv.conf a resolver, without which `bun x` cannot installAn Alpine rootfs with Bun installed already has four of them — musl carries
the loader, and installing Bun brings libstdc++ and libgcc in with it — so
the Alpine from Get a rootfs is where to copy them from. The
fifth, resolv.conf, is one line you write yourself.
Where Bun itself landed inside that Alpine depends on how it was installed —
npm puts it in /usr/local/bin, the official installer in ~/.bun/bin — so
ask the guest rather than guessing:
mkdir -p bare/bin bare/lib bare/etc
BUN=$(bunproot -S ./alpine /bin/sh -c 'command -v bun')
cp "./alpine$BUN" bare/bin/bun
cp alpine/lib/ld-musl-aarch64.so.1 bare/lib/
cp alpine/usr/lib/libstdc++.so.6 bare/lib/
cp alpine/usr/lib/libgcc_s.so.1 bare/lib/
echo 'nameserver 1.1.1.1' > bare/etc/resolv.confThat settles the symlink question by itself: cp follows a symbolic link it
is given as a source unless told otherwise, so Alpine's
libstdc++.so.6 -> libstdc++.so.6.0.34 and npm's
bun -> ../lib/node_modules/bun/bin/bun.exe arrive as real files under the
names the loader asks for.
Name each file the way the loader asks for it and no symbolic links are needed:
the loader at the PT_INTERP path (readelf -l shows it), each library at its
SONAME rather than its versioned filename — libstdc++.so.6, not
libstdc++.so.6.0.34. Copying a distribution's /lib verbatim brings its
symlink farm along, but none of it is load-bearing here. Bun's other
dependency, libc.musl-aarch64.so.1, needs no file of its own: musl's loader
is musl's libc, and it answers for that name itself.
That is a working guest:
bunproot -S ./bare /bin/bun -e 'console.log(process.platform, process.arch)'There is no shell in it, no coreutils, and no /usr/bin/env — a
#!/usr/bin/env NAME script still runs, because the tracer does the PATH
search itself rather than running an env that is not there. Give that guest a
network and bun x installs the rest, so a shell with ls, cat and curl is
one command away — here bunmsh, the Bun
Modern Shell, which carries those as builtins:
bunproot -S ./bare /bin/bun x bunmsh -c 'echo hi; ls /'Without -c the same command drops you into that shell, interactively, inside
the five-file guest:
bunproot -S ./bare /bin/bun x bunmshIt is not a small rootfs — Bun is 70-odd MiB — but it is one you can assemble by copying five files, with no distribution to download, unpack or trust.
It can likewise use Git without adding Git to those five files.
Usage
bunproot [OPTION ...] (-S ROOTFS | --android-container ROOTFS) [COMMAND [ARG ...]]Every option belongs before COMMAND. Parsing stops at the first argument that
is not an option, so a --kill-on-exit or a --help written after COMMAND
is an argument to the guest program instead.
| Option | Effect |
| --- | --- |
| -r, -S, --rootfs ROOTFS | Run with ROOTFS as the root directory. --rootfs=ROOTFS is also accepted; COMMAND defaults to /bin/sh |
| --android-container ROOTFS | Use ROOTFS, which may be an empty directory, with /system, /apex and the linker configuration bound in; /system/bin/sh is also /bin/sh, and this Bun is both /bin/bun and /bin/node. PATH starts with /system/bin; COMMAND defaults to /system/bin/sh. --android-container=ROOTFS says the same thing |
| -b, --bind HOST[:GUEST] | Make a host path visible inside the guest; repeatable. --bind=SPEC says the same thing |
| -m, --mount | Another name for --bind, not a different thing |
| -e, --env NAME[=VALUE] | Set a guest environment variable, or copy NAME from the host when no value is supplied; repeatable. --env=NAME[=VALUE] says the same thing |
| -u, --unset-env NAME | Remove a variable from the guest environment; repeatable. --unset-env=NAME says the same thing |
| -w, --cwd DIR | Start COMMAND in guest directory DIR. --cwd=DIR, --pwd DIR, and --pwd=DIR are equivalent |
| --dns MODE | Which resolver the guest gets: auto (default), simple or off. --dns=MODE says the same thing |
| -koe, --kill-on-exit | Kill whatever is left of the guest when COMMAND exits |
| -k, --kernel-release RELEASE | Replace only the release reported by uname(2). --kernel-release=RELEASE says the same thing; this identifies the kernel differently but does not emulate kernel features |
| -p [PORT SPEC] | With [HOST_IP:]HOST_PORT:CONTAINER_PORT[/tcp\|udp], rewrite guest ports to explicit host endpoints; repeatable. A bare -p enables low-port protection through PROOT_PORT_ADD. A single port is accepted for Docker CLI compatibility |
| --l2s-status ROOTFS | Report what state a rootfs's emulated hard-link store is in, then exit. Recognises the original PRoot's format too. Reads only, takes a rootfs of its own, and combines with nothing else |
| --l2s-pin ROOTFS | Rewrite that store so tools outside the rootfs can follow its emulated hard links. The guest loses them for as long as it is pinned, and the rootfs can no longer be moved |
| --l2s-unpin ROOTFS | Rewrite it back, so the guest can follow them again and the rootfs can move |
| --l2s-docs | Render link2symlink.md, the on-disk format, in the terminal |
| --l2s-ignore-pin | Enter a rootfs whose link store is pinned, which is otherwise refused. Its emulated hard links do not work while it is pinned |
| --setup.pulse | Print the setup PulseAudio recipe from tools/setup.pulse.sh, then exit |
| --download-alpine | Fetch and checksum an Alpine minirootfs, then exit. It must be the first argument, and takes no others |
| --download-alpine-x64 | The same download flow, but fetch an x86_64 Alpine minirootfs |
| --git [--yes] COMMAND [ARG ...] | Only as the first argument, ask before installing the separately locked isomorphic-git dependencies in place with scripts disabled, run the Git command with Git's own arguments and output, then exit. Add --yes immediately after --git to install without asking. --git --help lists the commands, --git --readme renders the guide |
| --x11-demo | Open the tiny dependency-free JavaScript X11 smoke-test window |
| --readme | Render this README in the terminal, with links where it has them |
| -h, --help | The options and the debug environment variables |
| -V, --version | The version, then exit |
PROOT_BUN_VERBOSE, PROOT_BUN_STRACE, PROOT_BUN_PROFILE,
PROOT_NO_SECCOMP and PROOT_IGNORE_MISSING_BINDINGS are environment
variables rather than options; Debugging is where they are
described.
The rootfs
ROOTFS may be relative. It is resolved to an absolute path before the
bootstrap changes its working directory.
Bindings
-b/--bind (-m/--mount) makes a host path visible inside the guest, and
may be repeated. -b HOST binds it at the same pathname; -b HOST:GUEST binds
it somewhere else. The first colon separates the two halves, and either half
may be relative to the caller's working directory:
bunproot -S ./alpine -b /sdcard -b ./sdk:/opt/sdk /bin/shThe most specific binding wins, so -b /opt/sdk:/usr/lib/sdk covers everything
below /usr/lib/sdk and nothing above it; the rootfs is simply the binding at
/. A binding whose host path does not exist is reported and dropped, as
upstream does; PROOT_IGNORE_MISSING_BINDINGS silences the report but still
drops it. /proc, /dev and /sys reach the host kernel filesystems without
needing a binding.
Environment
-e/--env follows Docker's two common forms. An assignment supplies an
explicit guest value; a bare name copies that variable from the host:
TOKEN=host-value bunproot -S ./alpine \
-e APP_MODE=production \
--env TOKEN \
/bin/sh -c 'printf "%s %s\\n" "$APP_MODE" "$TOKEN"'If a bare name is absent from the host, it remains absent in the guest. Use
-u/--unset-env to remove inherited variables explicitly. Both options are
repeatable and applied from left to right, so the last operation for a name
wins:
bunproot -S ./alpine -e DEBUG=1 -u DEBUG -e DEBUG=0 /bin/shPort mapping and low ports
The explicit form follows Docker's HOST_PORT:CONTAINER_PORT order and may be
repeated:
bunproot -S ./alpine \
-p 8080:80 \
-p 127.0.0.1:8443:443 \
-p 5353:53/udp \
/bin/serverThis maps guest TCP 80 to host TCP 8080, restricts guest TCP 443 to host
127.0.0.1:8443, and maps guest UDP 53 to host UDP 5353. TCP is the default;
use /udp explicitly for UDP. Bracket IPv6 host addresses, for example
-p '[::1]:8080:80'.
A single port is accepted as a Docker CLI compatibility no-op because the guest already shares the host network:
bunproot -S ./alpine -p 8080 /bin/serverFor low ports, use an explicit mapping or bare -p; the latter adds
PROOT_PORT_ADD (2000 by default) to every port below 1024:
bunproot -S ./alpine -p 2080:80 /bin/server
PROOT_PORT_ADD=3000 bunproot -p -S ./alpine /bin/serverThe precise syscall behavior and differences from Docker networking are in PORTING.md.
Hidden compatibility options for PRoot launchers
Some existing launchers always pass extension switches used by patched or upstream PRoot builds. bunproot enables the corresponding behavior without a switch, but accepts these spellings as no-ops so those launchers do not have to remove them first:
| Accepted compatibility option | Why it is a no-op in bunproot |
| --- | --- |
| -l, --link2symlink | Android-forbidden hard links are always handled by bunproot's portable link-to-symlink store |
| -L | bunproot always applies its symlink/path translation behavior; there is no optional symlink-size extension to turn on |
| --sysvipc | System V shared memory compatibility is automatic; other permitted System V IPC calls continue to use the host kernel |
| -0, --root-id, --change-id=0:0 | Every guest starts with the emulated root identity already. The separated spelling --change-id 0:0 is accepted too |
These options are intentionally omitted from --help: they exist only for
launcher compatibility and do not enable features. A --change-id value other
than 0:0 is rejected rather than silently pretending that arbitrary initial
identities are implemented.
Kernel release identification
-k RELEASE and --kernel-release RELEASE change the release shown by
uname; if repeated, the last value wins:
bunproot -S ./alpine --kernel-release=5.1.107-70-PRoot uname -aThis changes identification only, not the kernel's capabilities. See PORTING.md for its scope and the difference from upstream PRoot's compatibility extension.
For compatibility with applications that inspect it instead of calling
uname, bunproot also supplies a matching /proc/version. An explicit
-b FILE:/proc/version takes precedence.
The resolver
A rootfs straight from a distribution tarball often carries no
/etc/resolv.conf at all — Alpine's minirootfs does not ship one — so nothing
inside it can resolve a name until somebody writes one by hand. --dns binds
one in instead, which also keeps a read-only or throwaway rootfs usable without
editing it. This is not an upstream PRoot feature.
| Mode | What it binds |
| --- | --- |
| auto | The default: the bundled resolver, but only where the rootfs has none of its own |
| simple | The bundled resolver everywhere, overriding whatever the rootfs carries |
| off | Nothing at all; the rootfs answers for itself, with or without a resolver. no and false say the same thing |
Both /etc/resolv.conf and /tmp/resolv.conf are covered — the second is
where a guest with no writable /etc is left writing its own. Binding a file
needs no file at the guest end: the mount table answers for the pathname
whether or not the rootfs has it.
--dns is expanded where it is written, so it takes part in the same
last-one-wins rule as any other binding, and your own -b on the same pathname
beats it by coming after:
bunproot -S ./alpine --dns=simple /bin/sh -c 'cat /etc/resolv.conf'
bunproot -S ./alpine --dns=off -b ./my-resolv.conf:/etc/resolv.conf /bin/shWaiting for the guest
Like upstream PRoot, bunproot normally waits for every descendant of COMMAND
to exit. A browser or daemon may leave detached processes behind after an
interactive shell exits; use -koe/--kill-on-exit when leaving that shell
should terminate the whole guest session immediately:
bunproot -koe -S ./alpine /bin/shThe guest's own tools
The guest is a real distribution, so its own tools work:
bunproot -S ./alpine /bin/sh -c 'apk add npm && npm i -g bun'
bunproot -S ./alpine /bin/sh -c 'bun x cowsay hello'
bunproot -S ./alpine /usr/bin/git clone https://github.com/jjtseng93/jsmdcui /tmp/jsmdcuiTESTING.md collects these as the regression checks, with what each one tells you when it fails.
Git without system Git
Inside either minimal rootfs, a writable bun x installation can fetch
bunproot and use isomorphic-git without a
system Git. bunproot wraps isomorphic-git in Git's own command-line shape --
subcommands, options, output and exit statuses -- as a deliberately useful
subset. --git selects this mode only when it is the very first bunproot
argument, which is what makes an alias work. Every command it understands,
the everyday ones first:
alias git='bun x bunproot --git'git add hello.txt,git add -A-ufor tracked files only,-nto see what would be added.
git branch,git branch -a,git branch --show-current-d/-Ddelete,-mrenames,-u origin/mainsets the upstream.
git checkout main,git checkout -b feature,git checkout -- hi.txt- Staged and unstaged changes are carried across a switch, or refused
when they would be overwritten;
-fdiscards them.
- Staged and unstaged changes are carried across a switch, or refused
when they would be overwritten;
git clone --depth 1 -b main https://github.com/jjtseng93/jsmdcui- HTTP(S), local paths and
file://URLs;--depth,--shallow-since,--shallow-exclude,--single-branch,--no-tags.
- HTTP(S), local paths and
git commit -m "add hello",git commit -am rename,git commit --amend --no-edit- Without
-m/-Fthe message comes fromGIT_EDITOR,VISUALorEDITOR;--amendkeeps the original author unless--reset-author,--authoror--datesays otherwise. During a merge or cherry-pick the prepared message and parents are used.
- Without
git config user.name Me,git config --get user.name--globalwrites~/.gitconfig; identity may also come fromGIT_AUTHOR_*/GIT_COMMITTER_*.
git diff,git diff --cached,git diff main --stat,git diff main...feature --name-status- Runs on
bun pm diffand pairs exact renames;--check,--name-only,-U<n>,--exit-code,--no-index.
- Runs on
git fetch,git fetch --all,git fetch origin main- Reports the refs it moved like Git;
--depth,--deepen,--shallow-since,--shallow-exclude,--tags,-p.
- Reports the refs it moved like Git;
git init -q ../scratch--bare,-b <branch>.
git log --oneline -3,git log --oneline main..feature,git -c color.ui=never log --all --format="%h %s" -2--format,--since,--follow,--reverse,A..BandA...B. Output is coloured and decorated even in a pipe;-c color.ui=auto,--no-color,NO_COLORor--no-decorateturn that off.
git merge feature,git merge --no-ff feature,git merge --abort- Lands in the index and worktree and leaves unrelated local changes
alone; a conflict leaves
MERGE_HEAD, Git's markers and an unmergedstatus, thengit addandgit commitconclude it.
- Lands in the index and worktree and leaves unrelated local changes
alone; a conflict leaves
git mv hello.txt hi.txtgit pull,git pull --ff-only origin main- A fetch followed by a merge; there is no
--rebase.
- A fetch followed by a merge; there is no
git push -u origin main,git push --tags,git push -d origin old- Network remotes are HTTP(S) only, no SSH. Credentials are found the way
Git finds them --
GIT_TOKEN/GITHUB_TOKENorGIT_USERNAME/GIT_PASSWORD, then the configuredcredential.helper(store,!gh auth git-credential, any helper program), then~/.git-credentials; nothing ever prompts.
- Network remotes are HTTP(S) only, no SSH. Credentials are found the way
Git finds them --
git remote -v,git remote add upstream URL,git remote set-url origin URLgit reset --hard HEAD,git reset --soft HEAD~1,git reset hi.txtgit restore hi.txt,git restore --staged hi.txt--source=<tree-ish>restores from a commit instead of the index.
git rm -q hi.txt,git rm --cached hi.txtgit show --stat HEAD,git show v1 -s- An annotated tag is shown before what it points to; a shallow clone's boundary commit as a root commit.
git stash push -m wip,git stash list,git stash pop- Tracked files only, and apply/pop cannot abort on conflicts.
git status -s,git status-b,--porcelain,--ignored; paths are relative to the current directory except with--porcelain.
git switch main,git switch -c feature- The same rules as
checkout.
- The same rules as
git tag -a v1 -m first,git tag -l "v*",git tag -d v1
Less often, in the same alphabetical order:
git cat-file -p HEAD^{},git cat-file -t v1git check-ignore -q node_modulesgit cherry-pick feature~1,git cherry-pick -n feature- Keeps the picked author; a conflict follows the same course as a merge,
with
--continue,--skipand--abort.
- Keeps the picked author; a conflict follows the same course as a merge,
with
git commit-tree $(git write-tree) -p HEAD -m plumbedgit hash-object -w hi.txt--stdinand--stdin-pathstoo.
git ls-files,git ls-files -s,git ls-files -o- The current directory's subtree, relative to it, unless
--full-name.
- The current directory's subtree, relative to it, unless
git ls-remote --heads origin--tags,--refs,--symref,--exit-code.
git merge-base main feature,git merge-base --is-ancestor main featuregit mktag < tag.txtgit mktree </dev/null,git mktree --batchgit notes add -m note HEAD,git notes show HEAD,git notes list,git notes append,git notes copy,git notes remove,git notes prune--ref <notes-ref>selects another notes namespace.
git rev-parse --short HEAD,git rev-parse --show-toplevel,git rev-parse --abbrev-ref HEADgit show-ref --tags,git show-ref --headsgit update-ref refs/heads/backup HEAD,git update-ref -d refs/heads/backup--stdinunderstands update, create, delete and verify.
git version,git --versiongit write-tree
The global options go before the command: git --yes ... installs the locked
isomorphic-git without asking (later runs reuse it), git -C <path> ... runs
elsewhere, git -c <name>=<value> ... sets a value for one run.
git --help prints the command list, git <command> --help one command's
options, and git --readme renders the full guide for the terminal, links
and all, like bunproot --readme does (git --readme | less -R to page it):
tools/isomorphic-git/README.md: every
command's options, what the port adds on top of isomorphic-git, the known
limits, and the supply-chain notes behind the locked install and its
Install now? (Y/n) prompt.
Use with js-udocker
js-udocker can use bunproot as its container backend. Neither project bundles the other: bunproot is licensed under GPL-2.0-or-later, while js-udocker is licensed under Apache-2.0.
The bunproot --git clone steps below are intended to work on Android,
Linux, Windows and macOS. The final container command is Android-only because
it starts bunproot's Android/ARM64 tracer. If a first clone fails while Bun is
installing or updating packages, run bun pm cache rm to clear Bun's package
cache and try it again.
Create a directory containing sibling checkouts of both projects, point js-udocker at bunproot's source entry point, and run an Alpine container:
mkdir bunproot-udocker
cd bunproot-udocker
bunx bunproot --git clone https://github.com/jjtseng93/bunproot
bunx bunproot
bunx bunproot --git clone https://github.com/jjtseng93/js-udocker
cd js-udocker
export JS_UDOCKER_BUNPROOT="$(realpath ../bunproot/proot.js)"
bun udocker.js run --name=ap alpine
# In another invocation, list the managed containers:
bun udocker.js psIf bunproot is already checked out, clone js-udocker beside it and set
JS_UDOCKER_BUNPROOT to the absolute path of that checkout's proot.js; the
two repositories do not otherwise need to be installed system-wide.
Android compatibility
Some of what a Linux guest expects is refused by Android's own policy rather
than by the kernel, and the guest has no second way to ask. bunproot answers
those in the tracer, so nothing in the rootfs needs a wrapper, a host helper
process or an LD_PRELOAD.
Android SELinux denies a Linux guest's NETLINK_ROUTE queries. bunproot
substitutes a harmless datagram descriptor and synthesizes the link/address
dump from the tracer's native os.networkInterfaces() result. Consequently a
guest Bun can use os.networkInterfaces() without a wrapper, host Node helper,
or LD_PRELOAD.
-S also supplies /dev, maps /dev/udmabuf to /dev/null when a device
node is unavailable, and provides Android-hidden overflow uid/gid values. These
are built-in compatibility bindings; no Termux prefix bind is required.
Syscalls Android refuses to run
Android's seccomp policy is a second kind of refusal, and a harsher one: it does not fail the call, it kills the caller with SIGSYS. The tracer catches those and answers in the guest's place. What that answer is depends on what was blocked.
The id-setting family -- setuid, setgid, setregid, setresuid,
setfsuid, setgroups and the rest -- can never be granted to an app uid, so
fake-id0 answers them from its own credentials instead. Without it su stops
at can't set groups: Function not implemented.
accept(2) is missing from the allowlist; only accept4(2) is there,
which is what bionic's own accept() wraps. musl calls accept directly, so
the tracer rewinds the guest to its svc and reissues the call as accept4.
The guest then blocks in the kernel as it means to. Without this a musl server
spins on a socket it can never drain -- PostgreSQL logs could not accept new
connection: Function not implemented about ten times a second.
System V shared memory is denied outright, so shmget, shmat, shmdt
and shmctl are emulated. Segments are backed by files under TMPDIR that the
guest maps itself with MAP_SHARED, so two guest processes holding the same
key really do share memory rather than each getting a private copy. PostgreSQL
needs this even with shared_memory_type=mmap, because it still creates a
small SysV segment as its postmaster interlock. Semaphores and message queues
are not emulated; nothing tested has needed them.
Native bubblewrap
The guest's real, unmodified bwrap can run inside bunproot, without a wrapper
or LD_PRELOAD. Android does not grant the app real mount or user namespaces,
so bunproot emulates their syscalls and represents bind mounts, unmounts and
pivot_root transitions in a per-process runtime mount table.
/proc/self/mountinfo is synthesized from that table so bwrap validates the
same filesystem view that pathname translation enforces. The stock bwrap
arguments hidden in Flatpak's --args file descriptor are supported too.
This is compatibility, not a kernel security boundary: a bwrap payload is still protected only by bunproot's ptrace pathname isolation. See the native one-file-root regression in TESTING.md.
A writable TMPDIR is required, for bwrap's temporary sandbox roots and the
synthetic mountinfo files.
Flatpak through stock bwrap
Install Flatpak, bubblewrap, D-Bus and a portal backend in the rootfs. The
applications then run through the distribution's stock /usr/bin/bwrap:
# 1. Setup {
bunproot -S "$ROOTFS" /bin/sh
apk add fish flatpak xdg-desktop-portal-gtk font-dejavu
# apk add font-noto-cjk
fish
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install org.gnome.TextEditor
mkdir -p /run/user/0
# }
# 2. Run { (under fish)
export DISPLAY=:0
export XDG_SESSION_TYPE=x11
export XDG_RUNTIME_DIR=/run/user/0
dbus-run-session fish
flatpak run --filesystem=/root org.gnome.TextEditor &
# }
# flatpak run ANOTHER.APP
The X server must already accept TCP display 0; bunproot does not start it. On Termux with Termux:X11 installed, that is one command, run on the host side before the guest shell:
termux-x11 :0 -listen tcp -ac &-listen tcp is what puts display 0 on TCP, and -ac drops X's access
control, so keep this to a device you trust. Leave it running for as long as
the guest needs it. The DISPLAY=:0 above needs no .X11-unix binding of its
own: finding no Unix socket for that display inside the rootfs, it falls back
to TCP by itself.
dbus-run-session wraps the whole guest shell, not each application. Every
application launched from that shell inherits the same
DBUS_SESSION_BUS_ADDRESS and can communicate with the others. Exiting the
shell removes the session bus, so no persistent host or Termux D-Bus service
is required. Wrapping each flatpak run separately is suitable for an isolated
smoke test, but gives every application a different bus and is not a desktop
session.
Android normally denies app UIDs access to /dev/fuse. Consequently
xdg-document-portal cannot create its FUSE export at $XDG_RUNTIME_DIR/doc.
The D-Bus portal frontend and GTK FileChooser backend still work for paths the
sandbox can already access. The Text Editor example therefore grants its
trusted sandbox /root explicitly; the chooser remains enabled, but its
selection does not need a FUSE document export. Grant a narrower directory
instead when possible. This is a filesystem permission tradeoff, not a
bunproot or bwrap isolation guarantee.
The portal backend runs outside the Flatpak runtime and therefore needs at
least one font in the rootfs. On a fontless Alpine minirootfs, GTK can briefly
map the chooser and then resize it to tens of thousands of pixels, making it
look as though the dialog crashed. font-dejavu above supplies the minimal
host-side font set; application-runtime fonts do not replace it.
The launcher should normally already provide a writable TMPDIR; if a
minimal APK environment does not, set it to that app's cache directory. It
need not be a Termux path.
Flatpak's payload seccomp filter remains installed. bunproot removes only
the network part of --unshare-net or --unshare-all from bwrap's argument
stream because an Android app UID cannot configure loopback inside a real
network namespace. The other namespaces requested by --unshare-all remain
represented by bunproot. Consequently the emulated sandbox does not
provide network-namespace isolation.
What it needs
- An Android device where
ptraceis permitted for app processes. That is the normal case; a hardened or work-profile environment may not allow it. - Android's 64-bit linker at
/system/bin/linker64, and bionic — found in the Runtime APEX on Android 10 and later, in/system/lib64before that. - A Bun built for Android/bionic, not a glibc one, preferably 1.4.1 or newer.
Where it came from does not matter.
bunprootuses whateverbunis onPATH, which every route above provides; theprootshell launcher additionally falls back to abun-androidbeside it. - An ARM64 Linux rootfs containing the guest ELF and its
PT_INTERP.
Native library paths are resolved once in dlpath.js, which searches the
Runtime APEX and then /system/lib64 so one order covers every Android release
without asking the platform its version. JavaScript modules open those Android
libraries through ffi.js; guest libraries under ROOTFS/usr are never used as
the tracer's libc.
Guest syscalls are filtered with seccomp so the tracer only stops for the ones
it translates; syscall/seccomp.c.js builds the filter and PORTING.md
records what that is worth.
Environment handling is defined once in env.js: the tracer's own knobs, the
environment the Android bootstrap is started with, and the environment a guest
inherits. Other modules import from it rather than reading process.env.
Each original src/**/*.c or src/**/*.h has a corresponding **/*.c.js or
**/*.h.js here. The launcher effectively runs bun --no-orphans ./index.js;
--no-orphans covers Bun-owned subprocesses, and the tracer also enables
PTRACE_O_EXITKILL, which kills tracees if the tracer exits unexpectedly.
Debugging
Every knob is read in env.js. Names shared with the original PRoot keep the
original's name and semantics; the rest are prefixed PROOT_BUN_ because they
have no upstream counterpart.
| Variable | Effect |
| --- | --- |
| PROOT_BUN_VERBOSE=1 | Trace ELF loading, process events, signals, guest exec replacement and pathname rewriting. A memory fault also reports si_code, si_addr, and the mappings the faulting address and the faulting PC belong to. |
| PROOT_BUN_STRACE=1 | Print every guest syscall and its result, with ANSI colors by default. Use PROOT_BUN_STRACE=nocolor for plain text. Either value automatically disables syscall filtering; a result changed by the tracer shows both values, and a translated pathname also shows its host pathname. |
| PROOT_BUN_PROFILE=1 | On exit, report how many times the tracer stopped, how many of those stops it handled, how many pathnames it translated, and where the wall clock went. |
| PROOT_NO_SECCOMP | Set to any value to stop on every syscall instead of filtering. Upstream's variable, with upstream's semantics: presence is what counts. This is also the automatic fallback when the filter cannot be installed. |
| PROOT_IGNORE_MISSING_BINDINGS | Set to any value to drop a binding whose host path does not exist without reporting it. The binding is dropped either way; this silences the report. |
| PROOT_PORT_ADD | Offset used by bare -p to protect guest ports 1–1023; defaults to 2000. A Docker-style -p [HOST_IP:]HOST_PORT:CONTAINER_PORT[/tcp|udp] instead enables explicit mapping mode; -p CONTAINER_PORT is accepted as a no-op because the guest already shares the host network. |
PROOT_BUN_VERBOSE=1 PATH="$PWD:$PATH" LD_PRELOAD= \
bunproot -S "$ROOTFS" /bin/ls /For a syscall-by-syscall view, including the value or pathname the tracer substituted:
PROOT_BUN_STRACE=1 bunproot -S "$ROOTFS" /bin/cat /etc/os-releaseReading a profile: stops is what the tracer paid for and handled is what it
got. Without the seccomp filter the first is two per syscall the guest makes;
with it, two per syscall the port translates. A large gap between them means
the filter is off or is tracing more than it needs to.
PROOT_BUN_PROFILE=1 bunproot -S "$ROOTFS" /bin/sh -c 'bunx cowsay hello'Tests involving FFI must run as a native Android process. Bun inside a glibc PRoot cannot safely load Android bionic as a second libc.
Testing
TESTING.md is the checklist to run before trusting a change to the tracer: the unit tests, one integration check per area with what each one tells you when it fails, the bare rootfs some of them need, and the pitfalls that cost more time than the bugs did.
Implementation notes
- The initial Android process is
/system/bin/linker64 /system/bin/sh, stopped withSIGSTOP, then replaced by the JavaScript-controlled ELF loader. - The loader reads
PT_INTERPfrom the guest ELF, so glibc, musl, and static executables do not depend on a hard-coded guest loader path. - ARM64
PTRACE_SYSCALLstops rewrite guest pathname arguments intoROOTFS. /proc,/dev, and/sysuse the Android kernel filesystems.- The current vertical slice handles fork, vfork, clone, clone3, execve, and
#!interpreter chains. - Nested exec preserves the guest environment and applies
FD_CLOEXEC; tracee strings are read with the sameprocess_vm_readv-first strategy as upstream. - Guest-aware absolute symlinks work for both initial commands and nested exec;
for example Alpine's
/usr/bin/wget -> /bin/busyboxstays insideROOTFS. - Android app-data
linkatfailures use an exclusive-copy fallback, allowing Alpineapk updateto install downloaded repository indexes. Ordinary failed hard links use the relocatablerefs/objs/metsemulation described in link2symlink.md. --l2s-pinrewrites both legs of every emulated hard link to carry the rootfs's own pathname, which is what a backup or sync tool outside the guest needs to follow them;--l2s-unpinputs them back. Unpin before entering a pinned rootfs: the tracer identifies an emulated hard link by its guest-absolute target, so it recognises none of them while they are pinned, and binding the rootfs onto itself only hides that -- the files read back while reporting a link count of 1 and listing as symlinks. Every target is judged on its own, so either direction is idempotent and an interrupted run is finished by repeating it. A rootfs whose own pathname contains a.proot.l2ssegment is refused: nothing could tell the recorded prefix from the store path afterwards.- Entering a pinned rootfs is refused outright, because the damage is not
limited to the reads that fail: a new hard link would be written in the
portable form beside the pinned ones, and unlinking a pinned alias would
never decrement its object. The check samples a few refs on startup, which
also catches a store left halfway through a conversion;
--l2s-ignore-pinoverrides it. --l2s-statusnames every stale ref underPROOT_BUN_VERBOSE=1, and only counts them otherwise: a store can hold tens of thousands, and the count is what a normal report needs.--l2s-statusreads the store and derives everything from the symlink targets themselves, so a rootfs that was copied, moved or half-converted still describes what it actually is. Its concurrency is what makes a cold store cost about what a warm one does. It recognises an original-format/.l2sas well, and answers the one question that store cannot answer for itself: whether the host pathnames baked into it still lead anywhere.- An
AF_UNIXpathname travels in asockaddrrather than as a syscall pathname argument, sobindandconnectare translated separately. Abstract sockets stay in the host namespace. /proc/<PID>/{exe,cwd,root}is answered from tracer state rather than from the kernel, which still describes the Android bootstrap process because the guest image is mapped in instead ofexecved.- Reopening a descriptor through
/proc/self/fd/Nis denied by Android under ptrace, so the open becomesdup(N)as in upstream fake_id0, except for anO_PATHdescriptor, which needs the access mode the open asked for. - The emulated
execveresets signal dispositions and the alternate signal stack the way the real one does, so signals -- SIGCHLD in particular -- can be forwarded to the guest. Node'schild_processdepends on it. - Android's app seccomp policy reports a blocked syscall as SIGSYS. With the
default disposition the guest gets
ENOSYSto fall back on; a sandbox that installed a handler to broker the syscall is given the signal instead. - The initial stack reproduces the kernel's string layout, including the
ascending order within the argv and environment blocks that libuv measures
process.titleagainst. - The initial command goes through the same
#!expansion as a nestedexecve, so a script can be named directly on the command line. #!/usr/bin/env NAMEis resolved by the tracer against the guest's ownPATHwhen the rootfs ships noenv, so a rootfs holding nothing but Bun and its libraries still runs such a script. A guest that does shipenvkeeps using it.- The task name is set the way
execvesets it, so a guest process is not reported as the Android bootstrap'slinker64. -Sresolves a relative rootfs before changing cwd and enables the current fake-id0 layer (uid=0,gid=0, root supplementary group, and root ownership in common stat results).- That identity is not fixed at root: the set*id family moves a per-task
credential set, so a guest that drops privilege is seen to have dropped it,
and
getuid/geteuidanswer accordingly. A stat result hands back the saved set-user-ID for files the tracer's own uid owns, leaving everything else alone. PostgreSQL will not run as root, nor with a data directory it does not own, so it needs both halves. - That identity is translated in both directions across a Unix socket: real
ids go out in
SCM_CREDENTIALS, guest ids come back fromSO_PEERCRED. D-Bus authentication needs both halves. - System V shared memory is emulated over files under
TMPDIRthat the guest maps itself, so the tracer never has to pass a descriptor; upstream needs a helper process andSCM_RIGHTSfor that, and it is the part of its sysvipc extension that does not work on Android. - A syscall Android blocks arrives as SIGSYS, already past
svc. Some are answered outright, andacceptis reissued asaccept4by rewinding the guest one instruction; see Syscalls Android refuses to run. - A
NETLINK_ROUTEsocket is emulated rather than opened; see Android compatibility. - Mount and user namespaces are emulated in a per-process runtime mount table,
with
/proc/self/mountinfosynthesized from it; see Native bubblewrap. - A write into a tracee is checked against its writable mappings first: a fork or exec between a syscall's entry and its exit invalidates the buffer the entry remembered.
- Scratch slots are pooled and returned when a task exits, so a guest with hundreds of simultaneous tasks does not exhaust them.
--kill-on-exitstops tracing as soon as the root guest exits;PTRACE_O_EXITKILLtakes the remaining tracees with the tracer.- FFI symbols are bound at first use, not at import. Binding them at the top
of a module made
--version,--helpand--readmedepend on Android's bionic being loadable, which they have no need of. - Regenerate missing one-to-one placeholders with:
bun run generate-stubs.jsThis is not yet a drop-in replacement for upstream PRoot. See PORTING.md for implemented coverage and remaining work.
