npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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: /.l2s and /.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-rootfs

For 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'.
  • 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.shop confirmed) and non-root server
    • The server may need a port above 1024, the same account for sshd and the login, and relaxed StrictModes. Root sshd still needs chroot emulation.

Quick start

  • Below, bunproot means your route's spelling: bunx bunproot, npx bunproot, bun ./proot.js, or an installed bunproot.

  • 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 --help

Route 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 --help

That 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 --help

Use 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 --help

proot.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 ./empty

From 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/jsmdcui

On 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/xxx

There 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.js

This 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.ts

With 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 install

An 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.conf

That 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 bunmsh

It 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/sh

The 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/sh

Port 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/server

This 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/server

For 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/server

The 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 -a

This 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/sh

Waiting 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/sh

The 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/jsmdcui

TESTING.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
    • -u for tracked files only, -n to see what would be added.
  • git branch, git branch -a, git branch --show-current
    • -d/-D delete, -m renames, -u origin/main sets 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; -f discards them.
  • 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.
  • git commit -m "add hello", git commit -am rename, git commit --amend --no-edit
    • Without -m/-F the message comes from GIT_EDITOR, VISUAL or EDITOR; --amend keeps the original author unless --reset-author, --author or --date says otherwise. During a merge or cherry-pick the prepared message and parents are used.
  • git config user.name Me, git config --get user.name
    • --global writes ~/.gitconfig; identity may also come from GIT_AUTHOR_*/GIT_COMMITTER_*.
  • git diff, git diff --cached, git diff main --stat, git diff main...feature --name-status
    • Runs on bun pm diff and pairs exact renames; --check, --name-only, -U<n>, --exit-code, --no-index.
  • git fetch, git fetch --all, git fetch origin main
    • Reports the refs it moved like Git; --depth, --deepen, --shallow-since, --shallow-exclude, --tags, -p.
  • 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..B and A...B. Output is coloured and decorated even in a pipe; -c color.ui=auto, --no-color, NO_COLOR or --no-decorate turn 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 unmerged status, then git add and git commit conclude it.
  • git mv hello.txt hi.txt
  • git pull, git pull --ff-only origin main
    • A fetch followed by a merge; there is no --rebase.
  • 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_TOKEN or GIT_USERNAME/ GIT_PASSWORD, then the configured credential.helper (store, !gh auth git-credential, any helper program), then ~/.git-credentials; nothing ever prompts.
  • git remote -v, git remote add upstream URL, git remote set-url origin URL
  • git reset --hard HEAD, git reset --soft HEAD~1, git reset hi.txt
  • git 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.txt
  • git 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.
  • 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 v1
  • git check-ignore -q node_modules
  • git cherry-pick feature~1, git cherry-pick -n feature
    • Keeps the picked author; a conflict follows the same course as a merge, with --continue, --skip and --abort.
  • git commit-tree $(git write-tree) -p HEAD -m plumbed
  • git hash-object -w hi.txt
    • --stdin and --stdin-paths too.
  • git ls-files, git ls-files -s, git ls-files -o
    • The current directory's subtree, relative to it, unless --full-name.
  • git ls-remote --heads origin
    • --tags, --refs, --symref, --exit-code.
  • git merge-base main feature, git merge-base --is-ancestor main feature
  • git mktag < tag.txt
  • git mktree </dev/null, git mktree --batch
  • git 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 HEAD
  • git show-ref --tags, git show-ref --heads
  • git update-ref refs/heads/backup HEAD, git update-ref -d refs/heads/backup
    • --stdin understands update, create, delete and verify.
  • git version, git --version
  • git 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 ps

If 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 ptrace is 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/lib64 before that.
  • A Bun built for Android/bionic, not a glibc one, preferably 1.4.1 or newer. Where it came from does not matter. bunproot uses whatever bun is on PATH, which every route above provides; the proot shell launcher additionally falls back to a bun-android beside 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-release

Reading 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 with SIGSTOP, then replaced by the JavaScript-controlled ELF loader.
  • The loader reads PT_INTERP from the guest ELF, so glibc, musl, and static executables do not depend on a hard-coded guest loader path.
  • ARM64 PTRACE_SYSCALL stops rewrite guest pathname arguments into ROOTFS.
  • /proc, /dev, and /sys use 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 same process_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/busybox stays inside ROOTFS.
  • Android app-data linkat failures use an exclusive-copy fallback, allowing Alpine apk update to install downloaded repository indexes. Ordinary failed hard links use the relocatable refs/objs/mets emulation described in link2symlink.md.
  • --l2s-pin rewrites 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-unpin puts 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.l2s segment 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-pin overrides it.
  • --l2s-status names every stale ref under PROOT_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-status reads 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 /.l2s as well, and answers the one question that store cannot answer for itself: whether the host pathnames baked into it still lead anywhere.
  • An AF_UNIX pathname travels in a sockaddr rather than as a syscall pathname argument, so bind and connect are 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 of execved.
  • Reopening a descriptor through /proc/self/fd/N is denied by Android under ptrace, so the open becomes dup(N) as in upstream fake_id0, except for an O_PATH descriptor, which needs the access mode the open asked for.
  • The emulated execve resets 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's child_process depends on it.
  • Android's app seccomp policy reports a blocked syscall as SIGSYS. With the default disposition the guest gets ENOSYS to 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.title against.
  • The initial command goes through the same #! expansion as a nested execve, so a script can be named directly on the command line.
  • #!/usr/bin/env NAME is resolved by the tracer against the guest's own PATH when the rootfs ships no env, so a rootfs holding nothing but Bun and its libraries still runs such a script. A guest that does ship env keeps using it.
  • The task name is set the way execve sets it, so a guest process is not reported as the Android bootstrap's linker64.
  • -S resolves 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/geteuid answer 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 from SO_PEERCRED. D-Bus authentication needs both halves.
  • System V shared memory is emulated over files under TMPDIR that the guest maps itself, so the tracer never has to pass a descriptor; upstream needs a helper process and SCM_RIGHTS for 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, and accept is reissued as accept4 by rewinding the guest one instruction; see Syscalls Android refuses to run.
  • A NETLINK_ROUTE socket is emulated rather than opened; see Android compatibility.
  • Mount and user namespaces are emulated in a per-process runtime mount table, with /proc/self/mountinfo synthesized 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-exit stops tracing as soon as the root guest exits; PTRACE_O_EXITKILL takes 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, --help and --readme depend on Android's bionic being loadable, which they have no need of.
  • Regenerate missing one-to-one placeholders with:
bun run generate-stubs.js

This is not yet a drop-in replacement for upstream PRoot. See PORTING.md for implemented coverage and remaining work.

Contents