buninu-linux 0.10.56 → 0.11.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +14 -2
- package/README.md +78 -12
- package/initramfs/bin/buninu-land +26 -0
- package/initramfs/bin/chroot +68 -47
- package/initramfs/buninu/CHANGELOG.md +22 -0
- package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
- package/initramfs/buninu/apps/jsgotty/package.json +1 -1
- package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +1 -0
- package/initramfs/buninu/package.json +1 -1
- package/initramfs/lib/bunterm/geometry.js +15 -0
- package/initramfs/lib/bunterm/pointer-report.js +10 -0
- package/initramfs/lib/bunterm/terminal.js +5 -4
- package/initramfs/lib/chroot-options.js +69 -0
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +4 -1
- package/initramfs/usr/share/doc/buninu-linux/chroot.md +37 -3
- package/package.json +1 -1
- package/scripts/npm-packages.md +8 -3
- package/test/bunterm-pixelmouse.test.js +27 -0
- package/test/chroot-bind.test.js +70 -0
- package/test/fetch-npm.test.js +2 -2
- package/test/stage-npm.test.js +42 -1
package/NOTICE.md
CHANGED
|
@@ -284,8 +284,9 @@ committed source files, but their contents are redistributed in the image.
|
|
|
284
284
|
|
|
285
285
|
| Package | Version | Package license | Project / package page | Installed license and notices (relative to `/usr/lib/node_modules/`) |
|
|
286
286
|
|---|---|---|---|---|
|
|
287
|
-
| `buninu-browser` | 0.2.
|
|
288
|
-
|
|
|
287
|
+
| `buninu-browser` | 0.2.3 | MIT for the browser's own code; bundled components retain their own terms | https://github.com/jjtseng93/buninu-browser | `buninu-browser/LICENSE`, `THIRD_PARTY_NOTICES.md`, `LICENSES/` and vendor notices within that package |
|
|
288
|
+
| `buninu-land` | 0.1.2 | MIT for its own code; bundled components retain their own terms | https://github.com/jjtseng93/buninu-land | `buninu-land/LICENSE`, `THIRD_PARTY_NOTICES.md`, `LICENSES/`, `SOURCES.md`, protocol XML notices and vendor notices |
|
|
289
|
+
| `@drxiaozhi/casty` | 1.3.6 | MIT | https://github.com/jjtseng93/casty | `@drxiaozhi/casty/LICENSE` |
|
|
289
290
|
| `@drxiaozhi/jspulse` | 0.2.1 | LGPL-2.1-or-later, with additional component notices | https://github.com/jjtseng93/jspulse | `@drxiaozhi/jspulse/LICENSE`, `NOTICE.md`, `LICENSES/` and source-file notices within that package |
|
|
290
291
|
| `@drxiaozhi/jspinyin` | 0.1.1 | MIT; hanzipinyin material under the Unlicense | https://www.npmjs.com/package/@drxiaozhi/jspinyin | `@drxiaozhi/jspinyin/LICENSE` and `@drxiaozhi/jspinyin/LICENSE-hanzipinyin.md` |
|
|
291
292
|
|
|
@@ -317,6 +318,17 @@ LGPL-2.1-or-later. Within `/usr/lib/node_modules/buninu-browser/`, retain:
|
|
|
317
318
|
The decoder's `cpu.wasm` remains a separate, replaceable file. Do not remove
|
|
318
319
|
the source archive or rebuild material merely to reduce the image size.
|
|
319
320
|
|
|
321
|
+
### Buninu Land
|
|
322
|
+
|
|
323
|
+
The compositor package is extracted in full, including its CanvasKit WASM,
|
|
324
|
+
loader, protocol XML, source provenance, patches and license collection.
|
|
325
|
+
Retain `THIRD_PARTY_NOTICES.md`, `SOURCES.md`, `vendor-manifest.json`,
|
|
326
|
+
`LICENSES/`, `vendor/canvaskit/LICENSE`, and the notices embedded in the
|
|
327
|
+
Wayland protocol XML files. These cover Buninu Browser-derived code,
|
|
328
|
+
CanvasKit/Skia and embedded libraries, Wayland protocols, and the Hyprland
|
|
329
|
+
window-management/input reference credit (`LICENSES/hyprland.txt`).
|
|
330
|
+
The distribution-owned launcher uses the separately bundled Casty package.
|
|
331
|
+
|
|
320
332
|
### jspulse source and notices
|
|
321
333
|
|
|
322
334
|
jspulse is shipped as JavaScript source, with its LGPL license, modification
|
package/README.md
CHANGED
|
@@ -689,6 +689,7 @@ If there is no sound, check step 4's terminal for errors, and make sure
|
|
|
689
689
|
| --- | --- | --- |
|
|
690
690
|
| `buninu-linux-help` | render this buninu-linux README from `/usr/share/doc/buninu-linux/README.md` | `buninu-linux-help` |
|
|
691
691
|
| `buninu-browser` | early-stage, experimental JavaScript/WASM browser with the bundled casty terminal frontend; not yet a general-purpose browser replacement | `buninu-browser --help` |
|
|
692
|
+
| `buninu-land` | experimental software-rendered Wayland compositor with the bundled casty terminal frontend | `buninu-land --help` |
|
|
692
693
|
| `jspulse` | PulseAudio-compatible audio server and ALSA mixer controls; use `--alsa` on Buninu Linux | `jspulse --help` |
|
|
693
694
|
| `jspinyin` | Chinese pinyin input panel, opened with the system jsmdcui in TUI mode; not a system-wide input method | — |
|
|
694
695
|
| `mount` | `mount(2)` with type detection, `-o` parsing, `LABEL=`/`UUID=`, bind/move/remount; loads required filesystem and disk modules | `mount --help` → `/usr/share/doc/buninu-linux/mount.md` |
|
|
@@ -707,7 +708,7 @@ If there is no sound, check step 4's terminal for errors, and make sure
|
|
|
707
708
|
| `mmd`, `mcopy`, `mdir` | create directories, copy files and list contents in a FAT image without mounting it; use `-i IMAGE` and `::/path` | — |
|
|
708
709
|
| `parted` | create and inspect GPT partitions in a disk image; script-mode subset | `parted --help` |
|
|
709
710
|
| `stripansi` | remove ANSI escape sequences from stdin, `-`, or one or more files and concatenate the results | — |
|
|
710
|
-
| `chroot` | `chroot(2)` into another root directory, mounting `/proc`, `/sys`, `/dev`, `/dev/pts`, `/run`, `/tmp` and `/etc/resolv.conf` for you and
|
|
711
|
+
| `chroot` | `chroot(2)` into another root directory, mounting `/proc`, `/sys`, `/dev`, `/dev/pts`, `/run`, `/tmp` and `/etc/resolv.conf` for you; supports `-b HOST[:GUEST]` and `--shared-tmp`, with automatic cleanup on exit | `chroot --help` |
|
|
711
712
|
| `bunterm` | a graphical terminal on the framebuffer: Skia (CanvasKit) text with CJK, colour emoji, seamless box drawing, kitty graphics images, with the mouse working, wheel scrollback included (`--no-mouse` turns it off); xterm.js's emulator core over Bun's built-in PTY | `bunterm --help` |
|
|
712
713
|
|
|
713
714
|
Besides `bun` (and `sh`/`node` pointing at it), `/bin` includes the commands
|
|
@@ -738,6 +739,32 @@ REPL, then run `jspulse --alsa` in another terminal or use `jspulse --alsa &` to
|
|
|
738
739
|
buninu-browser about:audio
|
|
739
740
|
```
|
|
740
741
|
|
|
742
|
+
### Buninu Land (early-stage, experimental)
|
|
743
|
+
|
|
744
|
+
Run `buninu-land` inside `bunterm` (or another Kitty-graphics terminal) to
|
|
745
|
+
open the Wayland desktop. The compositor and Casty are bundled; startup
|
|
746
|
+
requires no npm download.
|
|
747
|
+
|
|
748
|
+
```sh
|
|
749
|
+
buninu-land
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
The socket directory defaults to `$TMPDIR`, or the system temporary directory
|
|
753
|
+
if `TMPDIR` is unset; an explicitly set `XDG_RUNTIME_DIR` takes precedence.
|
|
754
|
+
No environment setup is needed to launch the compositor.
|
|
755
|
+
|
|
756
|
+
In another terminal, set the Wayland client's `XDG_RUNTIME_DIR` to that socket
|
|
757
|
+
directory and `WAYLAND_DISPLAY` to the compositor's socket name (normally `wayland-buninu`;
|
|
758
|
+
additional instances use `wayland-buninu1`, `wayland-buninu2`, and so on),
|
|
759
|
+
then start a Wayland client. Clients inside a chroot can share the socket
|
|
760
|
+
with `chroot --shared-tmp` when the host's `TMPDIR` points to that directory.
|
|
761
|
+
|
|
762
|
+
The `/bin/buninu-land` launcher sets `CASTY_BROWSER` to
|
|
763
|
+
`/usr/lib/node_modules/buninu-land/compositor.js` and runs the bundled Casty.
|
|
764
|
+
`--casty` forwards arguments to Casty; `--headless`, `--help`, `--version`,
|
|
765
|
+
README options and reserved native frontends go through the package entry.
|
|
766
|
+
The package's CanvasKit WASM and license files are retained unchanged.
|
|
767
|
+
|
|
741
768
|
### Archive tools and other examples
|
|
742
769
|
|
|
743
770
|
The small `tar` reads the archive's own headers for links and listing, so
|
|
@@ -872,9 +899,45 @@ apk add libgcc libstdc++
|
|
|
872
899
|
# and /etc/resolv.conf are mounted for you and removed again on exit
|
|
873
900
|
chroot /mnt
|
|
874
901
|
chroot /mnt /bin/bun --version
|
|
875
|
-
chroot -n /mnt /bin/sh #
|
|
902
|
+
chroot -n /mnt /bin/sh # skip automatic mounts (explicit -b still applies)
|
|
876
903
|
```
|
|
877
904
|
|
|
905
|
+
|
|
906
|
+
Share host files and directories with repeatable `-b` options, placed before
|
|
907
|
+
the root filesystem path:
|
|
908
|
+
|
|
909
|
+
```sh
|
|
910
|
+
# Mount a host directory at a different path inside the guest
|
|
911
|
+
chroot -b /home/user/project:/work /mnt /bin/sh
|
|
912
|
+
|
|
913
|
+
# Omit the guest path to expose the same absolute path inside the guest
|
|
914
|
+
chroot -b /home/user/project /mnt /bin/sh
|
|
915
|
+
|
|
916
|
+
# Share multiple paths, including individual files
|
|
917
|
+
chroot -b /home/user/project:/work -b /etc/hosts:/etc/hosts /mnt /bin/sh
|
|
918
|
+
|
|
919
|
+
# Share the host temporary directory, including Wayland sockets
|
|
920
|
+
export TMPDIR=/tmp
|
|
921
|
+
chroot --shared-tmp /mnt /bin/sh
|
|
922
|
+
# Equivalent to:
|
|
923
|
+
chroot -b "$TMPDIR:/tmp" /mnt /bin/sh
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
`--shared-tmp` expands to `-b "$TMPDIR:/tmp"` and replaces the automatic
|
|
927
|
+
`/tmp` tmpfs. `TMPDIR` must be set and its source path must exist. The option
|
|
928
|
+
does not rewrite environment variables inside the guest; set `TMPDIR=/tmp`
|
|
929
|
+
there if the host uses a different path. For Buninu Land clients, set the
|
|
930
|
+
guest's `XDG_RUNTIME_DIR=/tmp` and `WAYLAND_DISPLAY` to the compositor's socket
|
|
931
|
+
name, and ensure the host's `TMPDIR` is the directory containing that socket.
|
|
932
|
+
|
|
933
|
+
Host paths may be relative; guest paths must be absolute. Missing guest
|
|
934
|
+
mount destinations are created. Directories are bound recursively, and a
|
|
935
|
+
repeated destination uses the last argument. Bind failures abort setup and
|
|
936
|
+
clean up its mounts, even with `-k`. Normally mounts are removed when the
|
|
937
|
+
command exits; `-k` / `--keep-mounts` keeps them after a successful setup.
|
|
938
|
+
Use `-v` to report mounts and unmounts. Full details: `chroot --help` or the
|
|
939
|
+
[chroot manual](initramfs/usr/share/doc/buninu-linux/chroot.md).
|
|
940
|
+
|
|
878
941
|
## Environment and dependencies
|
|
879
942
|
|
|
880
943
|
The default JS build uses host Bun. The native build-tool dependencies below
|
|
@@ -1101,7 +1164,7 @@ index.js entry point: -f / -b / -r / --arch / --docker / --expo
|
|
|
1101
1164
|
Dockerfile Debian-based oven/bun:1.4.2 plus the shell build toolchain
|
|
1102
1165
|
fetch-alpine.sh [fetch] kernel, EFI stub, musl, network/input/display modules
|
|
1103
1166
|
fetch-bun.sh [fetch] Bun, libstdc++, libgcc
|
|
1104
|
-
fetch-npm.sh [fetch] pinned browser, casty, jspulse and jspinyin tarballs (download only)
|
|
1167
|
+
fetch-npm.sh [fetch] pinned browser, land, casty, jspulse and jspinyin tarballs (download only)
|
|
1105
1168
|
build-uki.sh [build] UKI; calls scripts/pack-initramfs.sh
|
|
1106
1169
|
build-image.sh [build] GPT disk image; shared with the hello-world EFI
|
|
1107
1170
|
run-qemu.sh [run] QEMU q35 (x86_64) or virt (aarch64), KVM/HVF/TCG
|
|
@@ -1129,11 +1192,12 @@ tampered with is fetched again and has to pass the same hash before a script
|
|
|
1129
1192
|
extracts from it. Bumping a version changes both the file name and the hash, so
|
|
1130
1193
|
it always refetches.
|
|
1131
1194
|
|
|
1132
|
-
The
|
|
1133
|
-
`scripts/npm-packages.md`: `buninu-browser@0.2.3`,
|
|
1134
|
-
`@drxiaozhi/casty@1.3.
|
|
1195
|
+
The five application packages are pinned separately in
|
|
1196
|
+
`scripts/npm-packages.md`: `buninu-browser@0.2.3`, `buninu-land@0.1.2`,
|
|
1197
|
+
`@drxiaozhi/casty@1.3.6`, `@drxiaozhi/jspulse@0.2.1`, and
|
|
1135
1198
|
`@drxiaozhi/jspinyin@0.1.1`. These were npm's `latest` versions when checked
|
|
1136
|
-
on 2026-10-11, with buninu-browser updated
|
|
1199
|
+
on 2026-10-11, with buninu-browser updated on 2026-10-12, and
|
|
1200
|
+
casty 1.3.6 and buninu-land 0.1.2 verified on 2026-10-16;
|
|
1137
1201
|
fetch never resolves `latest` or a version range at runtime.
|
|
1138
1202
|
Each registry tarball was checked against its published SHA-512 integrity
|
|
1139
1203
|
before its SHA-256 was recorded in the shared pins file.
|
|
@@ -1159,7 +1223,7 @@ dependency resolution run. JS extraction uses the initramfs JS `tar` and
|
|
|
1159
1223
|
requires a host Bun; shell/Docker extraction uses the host `tar`, without Bun.
|
|
1160
1224
|
The source `initramfs/` and project `package.json` are not modified by packing.
|
|
1161
1225
|
|
|
1162
|
-
The image gets `/usr/lib/package.json` with the
|
|
1226
|
+
The image gets `/usr/lib/package.json` with the five exact dependencies and
|
|
1163
1227
|
standard `node_modules/.bin` links. This reserves a normal package-manager
|
|
1164
1228
|
layout for later `cd /usr/lib` and `bun i --backend=copyfile`; no lockfile is
|
|
1165
1229
|
fabricated. `bun i` alone retains the pinned versions, not newer releases.
|
|
@@ -1556,6 +1620,7 @@ for the userspace session.
|
|
|
1556
1620
|
* [Playing YouTube with sound (experimental)](#playing-youtube-with-sound-experimental)
|
|
1557
1621
|
- [Commands inside /bin](#commands-inside-bin)
|
|
1558
1622
|
* [Buninu Browser (early-stage, experimental)](#buninu-browser-early-stage-experimental)
|
|
1623
|
+
* [Buninu Land (early-stage, experimental)](#buninu-land-early-stage-experimental)
|
|
1559
1624
|
* [Archive tools and other examples](#archive-tools-and-other-examples)
|
|
1560
1625
|
- [Optional chroot and native binaries](#optional-chroot-and-native-binaries)
|
|
1561
1626
|
* [Debian and Chromium with casty](#debian-and-chromium-with-casty)
|
|
@@ -1609,8 +1674,8 @@ architecture under `native/<arch>/lib/`, and the built image redistributes
|
|
|
1609
1674
|
several more. Each stays
|
|
1610
1675
|
under its own terms, with platform license texts in [`LICENSES/`](LICENSES/).
|
|
1611
1676
|
|
|
1612
|
-
The build also fetches
|
|
1613
|
-
them under `/usr/lib/node_modules`: `buninu-browser`, `@drxiaozhi/casty`,
|
|
1677
|
+
The build also fetches five SHA-256-pinned npm releases and redistributes
|
|
1678
|
+
them under `/usr/lib/node_modules`: `buninu-browser`, `buninu-land`, `@drxiaozhi/casty`,
|
|
1614
1679
|
`@drxiaozhi/jspulse` and `@drxiaozhi/jspinyin`. Their license and notice files
|
|
1615
1680
|
remain inside their package directories. Exact download URLs and hashes are
|
|
1616
1681
|
in [`scripts/npm-packages.md`](scripts/npm-packages.md); versions, upstream
|
|
@@ -1630,9 +1695,10 @@ when redistributing the image.
|
|
|
1630
1695
|
| Linux kernels and networking, storage, xHCI, USB and HID modules | GPL-2.0-only with the Linux syscall note | image only |
|
|
1631
1696
|
| systemd EFI stub | LGPL-2.1-or-later | image only |
|
|
1632
1697
|
| Bun | MIT, plus the licenses of what it statically links | image only |
|
|
1633
|
-
| buninu-browser 0.2.
|
|
1698
|
+
| buninu-browser 0.2.3 | MIT for its own code; bundled components have their own licenses | npm download → image |
|
|
1699
|
+
| buninu-land 0.1.2 | MIT for its own code; bundled components retain their own licenses and notices | npm download → image |
|
|
1634
1700
|
| wasmpeg / FFmpeg (`@wasmpeg/cpu` 1.0.1, bundled in buninu-browser) | LGPL-2.1-or-later, plus component notices; source archive and rebuild documentation retained | browser package → image |
|
|
1635
|
-
| `@drxiaozhi/casty` 1.3.
|
|
1701
|
+
| `@drxiaozhi/casty` 1.3.6 | MIT | npm download → image |
|
|
1636
1702
|
| `@drxiaozhi/jspulse` 0.2.1 | LGPL-2.1-or-later, plus component notices | npm download → image |
|
|
1637
1703
|
| `@drxiaozhi/jspinyin` 0.1.1 | MIT; hanzipinyin material under the Unlicense | npm download → image |
|
|
1638
1704
|
| CanvasKit 0.41.1 (`initramfs/lib/canvaskit/`, Skia compiled to WebAssembly) | BSD-3-Clause (`LICENSES/BSD-3-Clause-Skia.txt`) | committed |
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/bun
|
|
2
|
+
// Keep the offline casty launcher outside the npm package so package updates
|
|
3
|
+
// preserve the distribution entry point.
|
|
4
|
+
import { resolve, join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const root = resolve(import.meta.dir, "..");
|
|
8
|
+
const modules = join(root, "usr/lib/node_modules");
|
|
9
|
+
const browser = join(modules, "buninu-land");
|
|
10
|
+
const entry = join(browser, "index.js");
|
|
11
|
+
const { chooseFrontend } = await import(pathToFileURL(entry).href);
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const choice = chooseFrontend(args);
|
|
14
|
+
const casty = choice.frontend === "casty";
|
|
15
|
+
const forwarded = casty ? choice.args : args;
|
|
16
|
+
// Bun consumes a leading separator after the script path; preserve Casty's
|
|
17
|
+
// own separator when --casty is followed directly by -- engine flags.
|
|
18
|
+
const runtimeArgs = forwarded[0] === "--" ? ["--", ...forwarded] : forwarded;
|
|
19
|
+
const child = Bun.spawn({
|
|
20
|
+
cmd: [process.execPath, casty ? join(modules, "@drxiaozhi/casty/index.js") : entry,
|
|
21
|
+
...runtimeArgs],
|
|
22
|
+
env: casty ? { ...process.env, CASTY_BROWSER: join(browser, "compositor.js") } : process.env,
|
|
23
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
24
|
+
});
|
|
25
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
|
|
26
|
+
process.exit(await child.exited);
|
package/initramfs/bin/chroot
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { chmodSync, existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { libc, cString, ptr, errno, strerror, Errno } from "../lib/dlopen.js";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { parseChrootArguments, resolveBindings, resolveGuestPath } from "../lib/chroot-options.js";
|
|
13
15
|
import { showDocument } from "../lib/document.js";
|
|
14
16
|
|
|
15
17
|
const MS = { RDONLY: 1n, NOSUID: 2n, NODEV: 4n, NOEXEC: 8n, BIND: 4096n, REC: 16384n, SLAVE: 1n << 19n };
|
|
@@ -23,27 +25,18 @@ const fail = (message, status = 125) => {
|
|
|
23
25
|
const usage = () => {
|
|
24
26
|
console.error(`Usage:
|
|
25
27
|
chroot NEWROOT [COMMAND [ARGUMENT...]]
|
|
26
|
-
chroot [-n] [-k] [-v] NEWROOT [COMMAND [ARGUMENT...]]
|
|
28
|
+
chroot [-n] [-k] [-v] [-b HOST[:GUEST]] [--shared-tmp] NEWROOT [COMMAND [ARGUMENT...]]
|
|
27
29
|
chroot -h | --help full manual (chroot.md)`);
|
|
28
30
|
process.exit(125);
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
// ------------------------------------------------------------- arguments --
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (positional.length > 0) { positional.push(argument); continue; }
|
|
39
|
-
if (argument === "--") { positional.push(...argv.slice(index + 1)); break; }
|
|
40
|
-
if (argument === "-h" || argument === "--help") { await showDocument("chroot"); process.exit(0); }
|
|
41
|
-
else if (argument === "-n" || argument === "--no-mount") options.mount = false;
|
|
42
|
-
else if (argument === "-k" || argument === "--keep-mounts") options.keep = true;
|
|
43
|
-
else if (argument === "-v" || argument === "--verbose") options.verbose = true;
|
|
44
|
-
else if (argument.startsWith("-") && argument.length > 1) { console.error(`chroot: unknown option ${argument}`); usage(); }
|
|
45
|
-
else positional.push(argument);
|
|
46
|
-
}
|
|
35
|
+
let parsed;
|
|
36
|
+
try { parsed = parseChrootArguments(process.argv.slice(2)); }
|
|
37
|
+
catch (error) { fail(error.message); }
|
|
38
|
+
const { options, positional } = parsed;
|
|
39
|
+
if (parsed.help) { await showDocument("chroot"); process.exit(0); }
|
|
47
40
|
if (positional.length === 0) usage();
|
|
48
41
|
|
|
49
42
|
const [target, ...command] = positional;
|
|
@@ -55,6 +48,10 @@ try {
|
|
|
55
48
|
fail(`cannot change root directory to ${target}: ${error.code === "ENOENT" ? "no such directory" : error.message}`);
|
|
56
49
|
}
|
|
57
50
|
if (root === "/") fail("refusing to chroot to /");
|
|
51
|
+
let bindings;
|
|
52
|
+
try { bindings = resolveBindings(options.binds); }
|
|
53
|
+
catch (error) { fail(`invalid bind: ${error.message}`); }
|
|
54
|
+
|
|
58
55
|
|
|
59
56
|
const report = (message) => { if (options.verbose) console.error(`chroot: ${message}`); };
|
|
60
57
|
|
|
@@ -80,57 +77,39 @@ const made = [];
|
|
|
80
77
|
// Prepares one mount. `inside` is the path within the new root. A target that
|
|
81
78
|
// is already a mount point, or that cannot be created on a read-only root, is
|
|
82
79
|
// left alone rather than treated as a failure: the command may still run.
|
|
83
|
-
const prepare = ({ inside, source, type, flags = 0n, data, file = false }) => {
|
|
80
|
+
const prepare = ({ inside, source, type, flags = 0n, data, file = false, required = false }) => {
|
|
81
|
+
inside = resolveGuestPath(root, inside);
|
|
82
|
+
if (!required && bindings.some(bind => {
|
|
83
|
+
const destination = resolveGuestPath(root, bind.inside);
|
|
84
|
+
return inside === destination || inside.startsWith(destination + "/");
|
|
85
|
+
})) return;
|
|
86
|
+
if (required && made.includes(inside)) throw Error(`bind destination is already used by this invocation: ${inside}`);
|
|
84
87
|
const point = `${root}${inside}`;
|
|
85
|
-
if (mountPoints().includes(point)) {
|
|
88
|
+
if (!required && mountPoints().includes(point)) {
|
|
86
89
|
report(`${inside} is already mounted`);
|
|
87
90
|
return;
|
|
88
91
|
}
|
|
89
92
|
if (!existsSync(point)) {
|
|
90
93
|
try {
|
|
91
|
-
if (file) writeFileSync(point, "");
|
|
94
|
+
if (file) { mkdirSync(dirname(point), { recursive: true, mode: 0o755 }); writeFileSync(point, "", { flag: "wx" }); }
|
|
92
95
|
else mkdirSync(point, { recursive: true, mode: 0o755 });
|
|
93
96
|
} catch (error) {
|
|
97
|
+
if (required) throw Error(`cannot create ${inside}: ${error.message}`);
|
|
94
98
|
report(`cannot create ${inside}: ${error.message}; skipped`);
|
|
95
99
|
return;
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
if (mountSyscall(source, point, type, flags, data) === -1) {
|
|
103
|
+
if (required) throw Error(`bind ${source} -> ${inside}: ${strerror(errno())}`);
|
|
99
104
|
report(`mount ${inside}: ${strerror(errno())}; skipped`);
|
|
100
105
|
return;
|
|
101
106
|
}
|
|
102
107
|
made.push(inside);
|
|
103
108
|
report(`mounted ${inside}`);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (options.mount) {
|
|
107
|
-
prepare({ inside: "/proc", source: "proc", type: "proc", flags: MS.NOSUID | MS.NODEV | MS.NOEXEC });
|
|
108
|
-
prepare({ inside: "/sys", source: "sysfs", type: "sysfs", flags: MS.NOSUID | MS.NODEV | MS.NOEXEC });
|
|
109
|
-
// /dev is bound rather than mounted fresh so the devices, the pty
|
|
110
|
-
// multiplexer and anything the host added are all there. The bind is then
|
|
111
|
-
// made a slave: mounts appearing inside do not propagate back to the host,
|
|
112
|
-
// and unmounting it later does not take the host's /dev with it.
|
|
113
|
-
prepare({ inside: "/dev", source: "/dev", type: "none", flags: MS.BIND | MS.REC });
|
|
114
|
-
if (made.includes("/dev")) {
|
|
115
|
-
if (mountSyscall("none", `${root}/dev`, "none", MS.REC | MS.SLAVE) === -1) {
|
|
116
|
-
report(`making /dev a slave mount: ${strerror(errno())}`);
|
|
117
|
-
}
|
|
118
|
-
// Only needed when the host has no /dev/pts of its own to bring along.
|
|
119
|
-
if (!existsSync(`${root}/dev/pts/ptmx`)) {
|
|
120
|
-
prepare({ inside: "/dev/pts", source: "devpts", type: "devpts", flags: MS.NOSUID | MS.NOEXEC, data: "gid=5,mode=620,ptmxmode=666" });
|
|
121
|
-
}
|
|
109
|
+
if (required && !file && mountSyscall("none", point, "none", MS.REC | MS.SLAVE) === -1) {
|
|
110
|
+
throw Error(`making ${inside} a slave mount: ${strerror(errno())}`);
|
|
122
111
|
}
|
|
123
|
-
|
|
124
|
-
prepare({ inside: "/tmp", source: "tmpfs", type: "tmpfs", flags: MS.NOSUID | MS.NODEV, data: "mode=1777" });
|
|
125
|
-
// A bound file, so name resolution inside follows the host's configuration
|
|
126
|
-
// and nothing inside the root is modified.
|
|
127
|
-
if (existsSync("/etc/resolv.conf") && existsSync(`${root}/etc`)) {
|
|
128
|
-
|
|
129
|
-
chmodSync(`/etc/resolv.conf`, 0o644);
|
|
130
|
-
|
|
131
|
-
prepare({ inside: "/etc/resolv.conf", source: "/etc/resolv.conf", type: "none", flags: MS.BIND, file: true });
|
|
132
|
-
}
|
|
133
|
-
}
|
|
112
|
+
};
|
|
134
113
|
|
|
135
114
|
// Mount points are recorded by their path inside the new root. Until
|
|
136
115
|
// chroot(2) has happened they are reached through this prefix; afterwards
|
|
@@ -167,10 +146,52 @@ const cleanup = () => {
|
|
|
167
146
|
|
|
168
147
|
process.on("exit", cleanup);
|
|
169
148
|
|
|
149
|
+
try {
|
|
150
|
+
if (options.mount) {
|
|
151
|
+
prepare({ inside: "/proc", source: "proc", type: "proc", flags: MS.NOSUID | MS.NODEV | MS.NOEXEC });
|
|
152
|
+
prepare({ inside: "/sys", source: "sysfs", type: "sysfs", flags: MS.NOSUID | MS.NODEV | MS.NOEXEC });
|
|
153
|
+
// /dev is bound rather than mounted fresh so the devices, the pty
|
|
154
|
+
// multiplexer and anything the host added are all there. The bind is then
|
|
155
|
+
// made a slave: mounts appearing inside do not propagate back to the host,
|
|
156
|
+
// and unmounting it later does not take the host's /dev with it.
|
|
157
|
+
prepare({ inside: "/dev", source: "/dev", type: "none", flags: MS.BIND | MS.REC });
|
|
158
|
+
if (made.includes("/dev")) {
|
|
159
|
+
if (mountSyscall("none", `${root}/dev`, "none", MS.REC | MS.SLAVE) === -1) {
|
|
160
|
+
report(`making /dev a slave mount: ${strerror(errno())}`);
|
|
161
|
+
}
|
|
162
|
+
// Only needed when the host has no /dev/pts of its own to bring along.
|
|
163
|
+
if (!existsSync(`${root}/dev/pts/ptmx`)) {
|
|
164
|
+
prepare({ inside: "/dev/pts", source: "devpts", type: "devpts", flags: MS.NOSUID | MS.NOEXEC, data: "gid=5,mode=620,ptmxmode=666" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
prepare({ inside: "/run", source: "tmpfs", type: "tmpfs", flags: MS.NOSUID | MS.NODEV, data: "mode=755" });
|
|
168
|
+
prepare({ inside: "/tmp", source: "tmpfs", type: "tmpfs", flags: MS.NOSUID | MS.NODEV, data: "mode=1777" });
|
|
169
|
+
// A bound file, so name resolution inside follows the host's configuration
|
|
170
|
+
// and nothing inside the root is modified.
|
|
171
|
+
if (existsSync("/etc/resolv.conf") && existsSync(`${root}/etc`)) {
|
|
172
|
+
|
|
173
|
+
chmodSync(`/etc/resolv.conf`, 0o644);
|
|
174
|
+
|
|
175
|
+
prepare({ inside: "/etc/resolv.conf", source: "/etc/resolv.conf", type: "none", flags: MS.BIND, file: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const binding of bindings) {
|
|
180
|
+
prepare({ ...binding, type: "none", flags: MS.BIND | (binding.file ? 0n : MS.REC), required: true });
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
// --keep-mounts applies to a prepared session, not a failed setup.
|
|
184
|
+
options.keep = false;
|
|
185
|
+
cleanup();
|
|
186
|
+
fail(error.message);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
170
190
|
// ----------------------------------------------------------------- enter --
|
|
171
191
|
|
|
172
192
|
if (libc.symbols.chroot(ptr(cString(root))) === -1) {
|
|
173
193
|
const code = errno();
|
|
194
|
+
options.keep = false;
|
|
174
195
|
cleanup();
|
|
175
196
|
fail(`cannot change root directory to ${target}: ${code === Errno.EPERM ? "must be superuser" : strerror(code)}`);
|
|
176
197
|
}
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.21 - 2026-10-15
|
|
4
|
+
|
|
5
|
+
- Sync jsgotty to 1.1.16: Turn on pixel report
|
|
6
|
+
|
|
7
|
+
## 0.4.20 - 2026-10-13
|
|
8
|
+
|
|
9
|
+
- Sync bunmsh to 0.4.0:
|
|
10
|
+
* Add a pure JavaScript awk fallback, file diff with line alignment, cmp,
|
|
11
|
+
uniq, expr, and egrep/fgrep aliases; extend sed and other utility builtins
|
|
12
|
+
for configure scripts and compiler builds.
|
|
13
|
+
* Improve compound commands, here-documents, parameter expansion, function
|
|
14
|
+
and loop control, descriptor redirection, and pipeline/subshell state
|
|
15
|
+
transfer for Autoconf and GCC bootstrap workloads.
|
|
16
|
+
* In `set -o posix` mode, use plain ls output and pass only exported variables
|
|
17
|
+
and command-prefix assignments to external programs. Default mode retains
|
|
18
|
+
ls icons and its existing environment behavior.
|
|
19
|
+
* Preserve the existing background-process behavior and stable
|
|
20
|
+
`Bun.sha.procs` indices. POSIX and awk compatibility remain partial; see
|
|
21
|
+
the bundled bunmsh PORTING.md for limitations.
|
|
22
|
+
- Sync jsgotty to 1.1.15: forward Kitty graphics to the terminal in `--client`
|
|
23
|
+
mode and avoid applying the corresponding cursor movement twice.
|
|
24
|
+
|
|
3
25
|
## 0.4.19 - 2026-10-12
|
|
4
26
|
|
|
5
27
|
- Sync bunmsh to 0.3.11: more POSIX syntax
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsgotty",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.16",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "jsgotty",
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.16",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"node-addon-api": "^7.1.1",
|
|
12
12
|
"ws": "^8.18.3"
|
|
@@ -12530,6 +12530,7 @@
|
|
|
12530
12530
|
(this.fontSizeOverride = fontSizeValue),
|
|
12531
12531
|
(this.term = new r.Terminal({
|
|
12532
12532
|
allowProposedApi: !0,
|
|
12533
|
+
windowOptions: { getWinSizePixels: true, getCellSizePixels: true },
|
|
12533
12534
|
customGlyphs: !0,
|
|
12534
12535
|
rescaleOverlappingGlyphs: !0,
|
|
12535
12536
|
fontFamily:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Report the content area in the same pixel units as SGR 1016, not fb margins.
|
|
2
|
+
export function attachGeometry({ term, renderer, reply }) {
|
|
3
|
+
term.options.windowOptions = { ...term.options.windowOptions, getWinSizePixels: true, getCellSizePixels: true };
|
|
4
|
+
return term.parser.registerCsiHandler({ final: 't' }, params => {
|
|
5
|
+
if (params[0] === 14 && params[1] !== 2) {
|
|
6
|
+
reply(`\x1b[4;${Math.round(term.rows * renderer.cellHeight)};${Math.round(term.cols * renderer.cellWidth)}t`);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
if (params[0] === 16) {
|
|
10
|
+
reply(`\x1b[6;${Math.round(renderer.cellHeight)};${Math.round(renderer.cellWidth)}t`);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Supply both coordinate systems: xterm selects cells (1006) or pixels (1016).
|
|
2
|
+
// Pixels are relative to the terminal content, excluding framebuffer margins.
|
|
3
|
+
export function terminalMouseEvent(report, renderer, button, action) {
|
|
4
|
+
const x = Math.floor(report.x - renderer.offsetX);
|
|
5
|
+
const y = Math.floor(report.y - renderer.offsetY);
|
|
6
|
+
return {
|
|
7
|
+
col: Math.floor(x / renderer.cellWidth), row: Math.floor(y / renderer.cellHeight),
|
|
8
|
+
x, y, button, action, ctrl: false, alt: false, shift: false,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -21,6 +21,8 @@ import { Renderer, defaultTheme } from "./renderer.js";
|
|
|
21
21
|
import { ImageStore } from "./images.js";
|
|
22
22
|
import { openKeyboard, translateKeys } from "./input.js";
|
|
23
23
|
import { claimVirtualConsole } from "./vt.js";
|
|
24
|
+
import { attachGeometry } from "./geometry.js";
|
|
25
|
+
import { terminalMouseEvent } from "./pointer-report.js";
|
|
24
26
|
import { attachClipboard } from "./clipboard.js";
|
|
25
27
|
|
|
26
28
|
const require = createRequire(import.meta.url);
|
|
@@ -160,6 +162,7 @@ export const createSession = ({
|
|
|
160
162
|
});
|
|
161
163
|
backend.onData(pump);
|
|
162
164
|
term.onData((data) => backend.write(Buffer.from(data, "utf8")));
|
|
165
|
+
attachGeometry({ term, renderer, reply: text => backend.write(Buffer.from(text, "utf8")) });
|
|
163
166
|
// OSC 52 copy and paste go straight to xclip; replies return to the program.
|
|
164
167
|
// Without it (--no-clipboard) xterm.js ignores OSC 52 and nothing reaches xclip.
|
|
165
168
|
if (clipboard) attachClipboard({ term, reply: (text) => backend.write(Buffer.from(text, "utf8")), onError });
|
|
@@ -181,7 +184,7 @@ export const createSession = ({
|
|
|
181
184
|
// Turns a pointer report from mouse.js into the mouse sequences the
|
|
182
185
|
// program asked for. xterm.js's CoreMouseService owns the protocol: it
|
|
183
186
|
// knows which events the program enabled (DECSET 1000/1002/1003), encodes
|
|
184
|
-
// them (SGR 1006 or the default form) and sends them through onData, so
|
|
187
|
+
// them (SGR 1006, pixel 1016, or the default form) and sends them through onData, so
|
|
185
188
|
// nothing here builds an escape sequence. Reports arrive only while a
|
|
186
189
|
// pointer is open, and this is the only place the terminal sees one.
|
|
187
190
|
const CoreMouseButton = { LEFT: 0, MIDDLE: 1, RIGHT: 2, NONE: 3, WHEEL: 4 };
|
|
@@ -223,11 +226,9 @@ export const createSession = ({
|
|
|
223
226
|
if (report.wheel) wheelScroll(report.wheel);
|
|
224
227
|
return;
|
|
225
228
|
}
|
|
226
|
-
const column = Math.floor((report.x - renderer.offsetX) / renderer.cellWidth);
|
|
227
|
-
const row = Math.floor((report.y - renderer.offsetY) / renderer.cellHeight);
|
|
228
229
|
const send = (button, action) => {
|
|
229
230
|
try {
|
|
230
|
-
service.triggerMouseEvent(
|
|
231
|
+
service.triggerMouseEvent(terminalMouseEvent(report, renderer, button, action));
|
|
231
232
|
} catch (error) {
|
|
232
233
|
onError(error);
|
|
233
234
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { realpathSync, statSync, lstatSync, readlinkSync } from 'node:fs';
|
|
2
|
+
import { posix, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function parseChrootArguments(argv, env = process.env) {
|
|
5
|
+
const options = { mount: true, keep: false, verbose: false, binds: [] };
|
|
6
|
+
let positional = [];
|
|
7
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8
|
+
const arg = argv[i];
|
|
9
|
+
if (arg === '--') { positional = argv.slice(i + 1); break; }
|
|
10
|
+
if (arg === '-h' || arg === '--help') return { options, positional, help: true };
|
|
11
|
+
if (arg === '-n' || arg === '--no-mount') options.mount = false;
|
|
12
|
+
else if (arg === '-k' || arg === '--keep-mounts') options.keep = true;
|
|
13
|
+
else if (arg === '-v' || arg === '--verbose') options.verbose = true;
|
|
14
|
+
else if (arg === '-b' || arg === '--bind') {
|
|
15
|
+
if (++i >= argv.length || !argv[i] || argv[i].startsWith('-')) throw Error(`${arg} requires HOST[:GUEST]`);
|
|
16
|
+
options.binds.push(argv[i]);
|
|
17
|
+
} else if (arg.startsWith('--bind=')) {
|
|
18
|
+
if (!arg.slice(7)) throw Error('--bind requires HOST[:GUEST]');
|
|
19
|
+
options.binds.push(arg.slice(7));
|
|
20
|
+
} else if (arg === '--shared-tmp') {
|
|
21
|
+
if (!env.TMPDIR) throw Error('--shared-tmp requires TMPDIR to be set');
|
|
22
|
+
options.binds.push(`${env.TMPDIR}:/tmp`);
|
|
23
|
+
} else if (arg.startsWith('-') && arg.length > 1) throw Error(`unknown option ${arg}`);
|
|
24
|
+
else { positional = argv.slice(i); break; }
|
|
25
|
+
}
|
|
26
|
+
return { options, positional, help: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveBindings(specs, cwd = process.cwd()) {
|
|
30
|
+
const bindings = new Map();
|
|
31
|
+
for (const spec of specs) {
|
|
32
|
+
const colon = spec.indexOf(':');
|
|
33
|
+
const host = colon < 0 ? spec : spec.slice(0, colon);
|
|
34
|
+
const guest = colon < 0 ? resolve(cwd, host) : spec.slice(colon + 1);
|
|
35
|
+
if (!host || !guest || host.includes('\0') || guest.includes('\0')) throw Error(`invalid bind: ${spec}`);
|
|
36
|
+
if (!posix.isAbsolute(guest)) throw Error(`bind guest path must be absolute: ${guest}`);
|
|
37
|
+
const inside = posix.normalize(guest);
|
|
38
|
+
if (inside === '/') throw Error('bind destination must not replace the chroot root');
|
|
39
|
+
const source = realpathSync(resolve(cwd, host));
|
|
40
|
+
bindings.set(inside, { inside, source, file: !statSync(source).isDirectory() });
|
|
41
|
+
}
|
|
42
|
+
// Parents must be mounted before children. For a repeated destination, the
|
|
43
|
+
// last argument wins, including --shared-tmp versus an explicit /tmp bind.
|
|
44
|
+
return [...bindings.values()].sort((a, b) => a.inside.split('/').length - b.inside.split('/').length);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Resolve guest symlinks as chroot would: absolute links start at NEWROOT,
|
|
48
|
+
// never at the host's /. Missing suffixes are created by the mount caller.
|
|
49
|
+
export function resolveGuestPath(root, inside) {
|
|
50
|
+
const todo = inside.split('/'), parts = [];
|
|
51
|
+
let links = 0;
|
|
52
|
+
while (todo.length) {
|
|
53
|
+
const part = todo.shift();
|
|
54
|
+
if (!part || part === '.') continue;
|
|
55
|
+
if (part === '..') { parts.pop(); continue; }
|
|
56
|
+
const candidate = `${root}/${[...parts, part].join('/')}`;
|
|
57
|
+
let entry;
|
|
58
|
+
try { entry = lstatSync(candidate); }
|
|
59
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
60
|
+
if (entry?.isSymbolicLink()) {
|
|
61
|
+
if (++links > 40) throw Error(`too many symlinks in bind destination: ${inside}`);
|
|
62
|
+
const target = readlinkSync(candidate);
|
|
63
|
+
if (target.startsWith('/')) parts.length = 0;
|
|
64
|
+
todo.unshift(...target.split('/'));
|
|
65
|
+
} else parts.push(part);
|
|
66
|
+
}
|
|
67
|
+
if (!parts.length) throw Error('bind destination resolves to the chroot root');
|
|
68
|
+
return '/' + parts.join('/');
|
|
69
|
+
}
|
|
@@ -82,7 +82,10 @@ Every pointing device under `/dev/input/event*` is opened and an arrow is
|
|
|
82
82
|
drawn on the screen. Clicks, drags and wheel notches reach the program as
|
|
83
83
|
the xterm mouse sequences it asked for: nothing is sent unless the program
|
|
84
84
|
enabled reporting (`DECSET 1000`, `1002` or `1003`), and what is sent follows
|
|
85
|
-
the mode it chose, SGR (`1006`)
|
|
85
|
+
the mode it chose, including SGR cell coordinates (`1006`) and SGR pixel
|
|
86
|
+
coordinates (`1016`). Mode 1016 also answers DECRQM queries; pixel positions
|
|
87
|
+
exclude the framebuffer margins and preserve movement within a cell.
|
|
88
|
+
CSI 14t/16t report the content and cell dimensions in those same pixel units. `jsmdcui`, `micro`, `htop`, `vim`
|
|
86
89
|
and anything else that speaks the protocol work with no configuration.
|
|
87
90
|
|
|
88
91
|
When the program is not reading the mouse, the wheel scrolls instead: back
|
|
@@ -11,7 +11,7 @@ every mount it made when the command exits.
|
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
13
|
chroot NEWROOT [COMMAND [ARGUMENT...]]
|
|
14
|
-
chroot [-n] [-k] [-v] NEWROOT [COMMAND [ARGUMENT...]]
|
|
14
|
+
chroot [-n] [-k] [-v] [-b HOST[:GUEST]] [--shared-tmp] NEWROOT [COMMAND [ARGUMENT...]]
|
|
15
15
|
chroot -h | --help
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -30,7 +30,7 @@ With no COMMAND it runs `$SHELL` if that exists inside NEWROOT, otherwise
|
|
|
30
30
|
| `/tmp` | `tmpfs` | scratch space that does not touch the root filesystem |
|
|
31
31
|
| `/etc/resolv.conf` | bind of the host's file | name resolution, without editing anything inside |
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
For automatic mounts, a destination that is already mounted is left alone. A directory that
|
|
34
34
|
is missing is created; if the root filesystem is read-only and that fails,
|
|
35
35
|
that one mount is skipped and the rest still happen — use `-v` to see which.
|
|
36
36
|
|
|
@@ -42,11 +42,30 @@ host's `/dev` with it.
|
|
|
42
42
|
|
|
43
43
|
| Option | Meaning |
|
|
44
44
|
|---|---|
|
|
45
|
-
| `-n`, `--no-mount` |
|
|
45
|
+
| `-n`, `--no-mount` | Skip automatic system mounts; explicit `-b` mounts still apply. Without binds, this is the behaviour of the traditional `chroot`. Note that Bun itself cannot start without `/proc` and `/dev/urandom`, so `-n` suits statically linked programs. |
|
|
46
|
+
| `-b HOST[:GUEST]`, `--bind HOST[:GUEST]` | Bind a host directory or file into the new root; repeat for multiple paths. Without GUEST, use HOST’s absolute path inside the guest. |
|
|
47
|
+
| `--shared-tmp` | Equivalent to `-b "$TMPDIR:/tmp"`; TMPDIR must be set and exist. Replaces the automatic `/tmp` tmpfs. |
|
|
46
48
|
| `-k`, `--keep-mounts` | Leave the mounts in place after the command exits, to enter the same root again cheaply. Clean up later with `umount -R`. |
|
|
47
49
|
| `-v`, `--verbose` | Report every mount and unmount. |
|
|
48
50
|
| `--` | End of options; everything after it is NEWROOT and COMMAND. |
|
|
49
51
|
|
|
52
|
+
Bind options must appear before NEWROOT. Host paths may be relative to the
|
|
53
|
+
current directory; guest paths must be absolute. Missing destination directories
|
|
54
|
+
and file placeholders are created. Guest symlinks are resolved relative to
|
|
55
|
+
NEWROOT, including absolute symlinks. Binding over the guest root `/` is rejected.
|
|
56
|
+
|
|
57
|
+
Explicit binds replace automatic mounts at or below their destination. Directories
|
|
58
|
+
are bound recursively and made slave mounts, like `/dev`; files use a plain bind.
|
|
59
|
+
A repeated destination uses the last argument, so a later `-b HOST:/tmp` can
|
|
60
|
+
override `--shared-tmp` (and vice versa). Parent binds are applied before children.
|
|
61
|
+
Explicit bind failures abort with status 125 and clean up this invocation's mounts,
|
|
62
|
+
even with `-k`. Existing mounts may be covered by a new explicit bind and are
|
|
63
|
+
revealed again when it is removed. Source data is shared, not copied.
|
|
64
|
+
|
|
65
|
+
`--shared-tmp` only adds the bind; it does not rewrite environment variables.
|
|
66
|
+
If the host's TMPDIR path is different from `/tmp`, applications in the guest may
|
|
67
|
+
need `TMPDIR=/tmp` in their environment.
|
|
68
|
+
|
|
50
69
|
## Examples
|
|
51
70
|
|
|
52
71
|
```sh
|
|
@@ -63,6 +82,21 @@ chroot -v /mnt /bin/sh -c 'ls /proc | head -3'
|
|
|
63
82
|
# Traditional behaviour: nothing is mounted for you
|
|
64
83
|
chroot -n /mnt /bin/sh
|
|
65
84
|
|
|
85
|
+
# Share a host directory at a different guest path
|
|
86
|
+
chroot -b /home/user/project:/work /mnt /bin/sh
|
|
87
|
+
|
|
88
|
+
# Share the same absolute path inside the guest
|
|
89
|
+
chroot -b /home/user/project /mnt /bin/sh
|
|
90
|
+
|
|
91
|
+
# Share files, or multiple paths
|
|
92
|
+
chroot -b /home/user/project:/work -b /etc/hosts:/etc/hosts /mnt /bin/sh
|
|
93
|
+
|
|
94
|
+
# Share the host temporary directory (including Unix sockets)
|
|
95
|
+
export TMPDIR=/tmp
|
|
96
|
+
chroot --shared-tmp /mnt /bin/sh
|
|
97
|
+
# Equivalent explicit bind:
|
|
98
|
+
chroot -b "$TMPDIR:/tmp" /mnt /bin/sh
|
|
99
|
+
|
|
66
100
|
# Keep the mounts for repeated visits
|
|
67
101
|
chroot -k /mnt /bin/sh -c 'true'
|
|
68
102
|
chroot -k /mnt
|
package/package.json
CHANGED
package/scripts/npm-packages.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
# npm packages
|
|
2
2
|
# Latest versions checked on 2026-10-11; SHA-256 pins verified against registry SHA-512 integrity.
|
|
3
3
|
# buninu-browser updated and verified on 2026-10-12.
|
|
4
|
+
# casty 1.3.6 and buninu-land 0.1.2 verified on 2026-10-16.
|
|
4
5
|
|
|
5
6
|
"buninu-browser@0.2.3":
|
|
6
7
|
- https://registry.npmjs.org/buninu-browser/-/buninu-browser-0.2.3.tgz
|
|
7
8
|
- sha256:0f7c9cd9df689d7b0900fdf3172851125ebd8aba12bed555be7e091d427146e8
|
|
8
9
|
|
|
9
|
-
"@
|
|
10
|
-
- https://registry.npmjs.org
|
|
11
|
-
- sha256:
|
|
10
|
+
"buninu-land@0.1.2":
|
|
11
|
+
- https://registry.npmjs.org/buninu-land/-/buninu-land-0.1.2.tgz
|
|
12
|
+
- sha256:0b682e1d57f953d9fa9415369c320e4e35bf7575c2859673c26a0789f5369490
|
|
13
|
+
|
|
14
|
+
"@drxiaozhi/casty@1.3.6":
|
|
15
|
+
- https://registry.npmjs.org/@drxiaozhi/casty/-/casty-1.3.6.tgz
|
|
16
|
+
- sha256:2b5f84e3a63c2c16fa92dc88faa43eec54edc2fe69154e5270af3cd9e7f472b8
|
|
12
17
|
|
|
13
18
|
"@drxiaozhi/jspulse@0.2.1":
|
|
14
19
|
- https://registry.npmjs.org/@drxiaozhi/jspulse/-/jspulse-0.2.1.tgz
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {Terminal} from '../initramfs/lib/xterm/xterm-headless.mjs';
|
|
3
|
+
import {terminalMouseEvent} from '../initramfs/lib/bunterm/pointer-report.js';
|
|
4
|
+
test('bunterm negotiates 1016 and emits real pixel press, release and same-cell motion',async()=>{
|
|
5
|
+
const term=new Terminal({cols:80,rows:24,allowProposedApi:true}),out=[];term.onData(s=>out.push(s));
|
|
6
|
+
const write=s=>new Promise(r=>term.write(s,r));
|
|
7
|
+
const renderer={offsetX:5,offsetY:7,cellWidth:10,cellHeight:20};
|
|
8
|
+
const send=(x,y,action)=>term._core.coreMouseService.triggerMouseEvent(terminalMouseEvent({x,y},renderer,0,action));
|
|
9
|
+
try{
|
|
10
|
+
await write('\x1b[?1016$p');expect(out.pop()).toBe('\x1b[?1016;2$y');
|
|
11
|
+
await write('\x1b[?1003h\x1b[?1006h\x1b[?1016h\x1b[?1016$p');expect(out.pop()).toBe('\x1b[?1016;1$y');
|
|
12
|
+
send(30,68,1);expect(out.pop()).toBe('\x1b[<0;25;61M');
|
|
13
|
+
send(30,68,0);expect(out.pop()).toBe('\x1b[<0;25;61m');
|
|
14
|
+
send(31,68,32);expect(out.pop()).toBe('\x1b[<32;26;61M');
|
|
15
|
+
send(32,68,32);expect(out.pop()).toBe('\x1b[<32;27;61M');
|
|
16
|
+
await write('\x1b[?1016l\x1b[?1006h');send(30,68,1);expect(out.pop()).toBe('\x1b[<0;3;4M');
|
|
17
|
+
}finally{term.dispose();}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('geometry queries use terminal content pixels and exclude framebuffer margins',async()=>{
|
|
21
|
+
const {attachGeometry}=await import('../initramfs/lib/bunterm/geometry.js');
|
|
22
|
+
const term=new Terminal({cols:80,rows:24,allowProposedApi:true}),out=[];
|
|
23
|
+
const renderer={offsetX:5,offsetY:7,cellWidth:10,cellHeight:20};
|
|
24
|
+
const handler=attachGeometry({term,renderer,reply:s=>out.push(s)});
|
|
25
|
+
try{await new Promise(r=>term.write('\x1b[14t\x1b[16t',r));expect(out).toEqual(['\x1b[4;480;800t','\x1b[6;20;10t']);}
|
|
26
|
+
finally{handler.dispose();term.dispose();}
|
|
27
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,symlinkSync,rmSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {parseChrootArguments,resolveBindings,resolveGuestPath} from '../initramfs/lib/chroot-options.js';
|
|
6
|
+
|
|
7
|
+
test('chroot bind arguments expand shared tmp, repeat, and stop at NEWROOT',()=>{
|
|
8
|
+
const result=parseChrootArguments(['-n','-b','/host:/guest','--shared-tmp','--bind=/same','/root','echo','-b','unchanged'],{TMPDIR:'/host temp'});
|
|
9
|
+
expect(result.options).toMatchObject({mount:false,binds:['/host:/guest','/host temp:/tmp','/same']});
|
|
10
|
+
expect(result.positional).toEqual(['/root','echo','-b','unchanged']);
|
|
11
|
+
expect(()=>parseChrootArguments(['--shared-tmp','/root'],{})).toThrow('TMPDIR');
|
|
12
|
+
expect(()=>parseChrootArguments(['-b'])).toThrow('requires');
|
|
13
|
+
expect(()=>parseChrootArguments(['--bind='])).toThrow('requires');
|
|
14
|
+
expect(parseChrootArguments(['--','-root','sh']).positional).toEqual(['-root','sh']);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('bind resolution preserves same-path destinations and confines guest symlinks',()=>{
|
|
18
|
+
const dir=mkdtempSync(join(tmpdir(),'buninu-bind-path-'));
|
|
19
|
+
try{
|
|
20
|
+
mkdirSync(join(dir,'root'));mkdirSync(join(dir,'host'));writeFileSync(join(dir,'file'),'data');
|
|
21
|
+
const binds=resolveBindings(['host','file:/etc/config','host:/tmp','file:/tmp'],dir);
|
|
22
|
+
expect(binds.find(b=>b.inside===join(dir,'host'))?.source).toBe(join(dir,'host'));
|
|
23
|
+
expect(binds.find(b=>b.inside==='/tmp')?.file).toBe(true);
|
|
24
|
+
expect(()=>resolveBindings(['host:relative'],dir)).toThrow('absolute');
|
|
25
|
+
expect(()=>resolveBindings(['host:/'],dir)).toThrow('root');
|
|
26
|
+
expect(()=>resolveBindings(['missing:/ok'],dir)).toThrow();
|
|
27
|
+
symlinkSync('/run/tmp',join(dir,'root/tmp'));
|
|
28
|
+
expect(resolveGuestPath(join(dir,'root'),'/tmp')).toBe('/run/tmp');
|
|
29
|
+
symlinkSync('../../outside',join(dir,'root/escape'));
|
|
30
|
+
expect(resolveGuestPath(join(dir,'root'),'/escape')).toBe('/outside');
|
|
31
|
+
}finally{rmSync(dir,{recursive:true,force:true});}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Execute the actual CLI with recorded syscall bindings. No host mounts or
|
|
35
|
+
// chroot occur; this also exercises failure cleanup and the -n interaction.
|
|
36
|
+
async function invoke(argumentsFor,env={}) {
|
|
37
|
+
const dir=mkdtempSync(join(tmpdir(),'buninu-bind-cli-')),root=join(dir,'root'),host=join(dir,'host'),log=join(dir,'calls');
|
|
38
|
+
mkdirSync(root);mkdirSync(host);mkdirSync(join(dir,'fail'));writeFileSync(join(dir,'file'),'data');
|
|
39
|
+
const mock=join(dir,'libc.js');
|
|
40
|
+
writeFileSync(mock,`import {appendFileSync} from 'node:fs';
|
|
41
|
+
const record=(name,...args)=>appendFileSync(${JSON.stringify(log)},JSON.stringify([name,...args.map(a=>typeof a==='bigint'?String(a):a)])+'\\n');
|
|
42
|
+
export const ptr=x=>x,cString=x=>x,errno=()=>1,strerror=()=> 'mock mount denied',Errno={EINVAL:22,EPERM:1};
|
|
43
|
+
export const libc={symbols:{mount:(...a)=>{record('mount',...a);return a[0]?.endsWith('/fail')?-1:0;},umount2:(...a)=>{record('umount',...a);return 0;},chroot:(...a)=>{record('chroot',...a);return 0;}}};`);
|
|
44
|
+
const source=readFileSync(new URL('../initramfs/bin/chroot',import.meta.url),'utf8')
|
|
45
|
+
.replace('"../lib/dlopen.js"',JSON.stringify(mock))
|
|
46
|
+
.replace('"../lib/chroot-options.js"',JSON.stringify(new URL('../initramfs/lib/chroot-options.js',import.meta.url).pathname))
|
|
47
|
+
.replace('import { showDocument } from "../lib/document.js";','const showDocument=async()=>{};');
|
|
48
|
+
const entry=join(dir,'chroot.mjs');writeFileSync(entry,source);
|
|
49
|
+
try{
|
|
50
|
+
const child=Bun.spawn([process.execPath,entry,...argumentsFor({dir,root,host}),root,'/bin/true'],{env:{...process.env,...env,TMPDIR:host},stdout:'pipe',stderr:'pipe'});
|
|
51
|
+
const stderr=new Response(child.stderr).text();const status=await child.exited;
|
|
52
|
+
return {status,stderr:await stderr,calls:readFileSync(log,'utf8').trim().split('\n').map(JSON.parse),root,host,dir};
|
|
53
|
+
}finally{rmSync(dir,{recursive:true,force:true});}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('explicit binds work with -n, create files, and unwind in reverse depth order',async()=>{
|
|
57
|
+
const r=await invoke(({dir,host})=>['-n','-b',`${host}:/work`,'-b',`${dir}/file:/work/config/file`]);
|
|
58
|
+
expect(r.status).toBe(0);
|
|
59
|
+
expect(r.calls.filter(c=>c[0]==='mount'&&c[1]===r.host)[0]).toEqual(['mount',r.host,r.root+'/work','none','20480',null]);
|
|
60
|
+
expect(r.calls.filter(c=>c[0]==='umount').map(c=>c[1])).toEqual(['/work/config/file','/work']);
|
|
61
|
+
});
|
|
62
|
+
test('shared tmp replaces default tmpfs and bind failure cleans up even with -k',async()=>{
|
|
63
|
+
const shared=await invoke(()=>['--shared-tmp']);expect(shared.status).toBe(0);
|
|
64
|
+
expect(shared.calls.some(c=>c[0]==='mount'&&c[1]===shared.host&&c[2]===shared.root+'/tmp')).toBe(true);
|
|
65
|
+
expect(shared.calls.some(c=>c[0]==='mount'&&c[1]==='tmpfs'&&c[2]===shared.root+'/tmp')).toBe(false);
|
|
66
|
+
const failure=await invoke(({host,dir})=>['-n','-k','-b',`${host}:/first`,'-b',`${dir}/fail:/second`]);
|
|
67
|
+
expect(failure.status).toBe(125);expect(failure.stderr).toContain('mock mount denied');
|
|
68
|
+
expect(failure.calls.some(c=>c[0]==='chroot')).toBe(false);
|
|
69
|
+
expect(failure.calls.filter(c=>c[0]==='umount').map(c=>c[1])).toEqual([failure.root+'/first']);
|
|
70
|
+
});
|
package/test/fetch-npm.test.js
CHANGED
|
@@ -21,9 +21,9 @@ const server = Bun.serve({
|
|
|
21
21
|
afterAll(() => { server.stop(true); rmSync(work, { recursive: true, force: true }); });
|
|
22
22
|
const url = `http://127.0.0.1:${server.port}/archive`;
|
|
23
23
|
|
|
24
|
-
test("
|
|
24
|
+
test("five exact registry pins share an architecture-independent cache", () => {
|
|
25
25
|
expect(npmPackages.map(({ name, version }) => `${name}@${version}`)).toEqual([
|
|
26
|
-
"buninu-browser@0.2.3", "@drxiaozhi/casty@1.3.
|
|
26
|
+
"buninu-browser@0.2.3", "buninu-land@0.1.2", "@drxiaozhi/casty@1.3.6",
|
|
27
27
|
"@drxiaozhi/jspulse@0.2.1", "@drxiaozhi/jspinyin@0.1.1",
|
|
28
28
|
]);
|
|
29
29
|
expect(basename(npmDownloadsDir)).toBe("npm");
|
package/test/stage-npm.test.js
CHANGED
|
@@ -16,7 +16,7 @@ const fresh = () => { const dir = join(work, String(sequence++)); mkdirSync(dir)
|
|
|
16
16
|
async function fixture() {
|
|
17
17
|
const downloadsDir = fresh();
|
|
18
18
|
const packages = [];
|
|
19
|
-
for (const name of ["buninu-browser", "@drxiaozhi/casty", "@drxiaozhi/jspulse", "@drxiaozhi/jspinyin"]) {
|
|
19
|
+
for (const name of ["buninu-browser", "buninu-land", "@drxiaozhi/casty", "@drxiaozhi/jspulse", "@drxiaozhi/jspinyin"]) {
|
|
20
20
|
const bin = name.split("/").pop();
|
|
21
21
|
const data = await new Bun.Archive({
|
|
22
22
|
"package/package.json": JSON.stringify({ name, version: "1.0.0", type: "module", bin: { [bin]: "index.js" } }),
|
|
@@ -104,6 +104,39 @@ test("distribution launcher ensures resources then forwards to local casty witho
|
|
|
104
104
|
expect(JSON.parse(result.stdout)).toEqual({ args: ["https://example.test", "--", "--chromium"], engine: join(browser, "buninu-browser.js") });
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
test("land launcher selects bundled casty and preserves package frontend dispatch", async () => {
|
|
108
|
+
const cfg = await fixture(), root = fresh();
|
|
109
|
+
stageNpm(root, cfg);
|
|
110
|
+
const land = join(root, "usr/lib/node_modules/buninu-land");
|
|
111
|
+
mkdirSync(join(root, "bin"));
|
|
112
|
+
const launcher = join(root, "bin/buninu-land");
|
|
113
|
+
copyFileSync(new URL("../initramfs/bin/buninu-land", import.meta.url), launcher);
|
|
114
|
+
writeFileSync(join(land, "index.js"), `
|
|
115
|
+
export const chooseFrontend = args => ({
|
|
116
|
+
frontend: !args.length || args[0] === '--casty' || args[0] === 'about:blank' ? 'casty' : 'other',
|
|
117
|
+
args: args[0] === '--casty' ? args.slice(1) : args,
|
|
118
|
+
});
|
|
119
|
+
if (import.meta.main) console.log(JSON.stringify({ package: true, args: process.argv.slice(2) }));
|
|
120
|
+
`);
|
|
121
|
+
writeFileSync(join(root, "usr/lib/node_modules/@drxiaozhi/casty/index.js"),
|
|
122
|
+
"console.log(JSON.stringify({args:process.argv.slice(2),engine:process.env.CASTY_BROWSER}));");
|
|
123
|
+
for (const args of [[], ['about:blank'], ['--casty', '--', '--socket=wayland-test']]) {
|
|
124
|
+
const result = spawnSync(process.execPath, [launcher, ...args], {
|
|
125
|
+
encoding: "utf8", env: { ...process.env, CASTY_BROWSER: '/previous/browser' },
|
|
126
|
+
});
|
|
127
|
+
expect(result.status).toBe(0);
|
|
128
|
+
expect(JSON.parse(result.stdout)).toEqual({
|
|
129
|
+
args: args[0] === '--casty' ? args.slice(1) : args,
|
|
130
|
+
engine: join(land, "compositor.js"),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
for (const args of [['--headless', '--help'], ['--help'], ['--version'], ['--docs'], ['--gtk']]) {
|
|
134
|
+
const result = spawnSync(process.execPath, [launcher, ...args], { encoding: "utf8" });
|
|
135
|
+
expect(result.status).toBe(0);
|
|
136
|
+
expect(JSON.parse(result.stdout)).toEqual({ package: true, args });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
107
140
|
test.skipIf(!npmPackages.every(pkg => existsSync(join(npmDownloadsDir, pkg.archive))))("real cached pins: JS and shell staging agree; browser headless help starts", () => {
|
|
108
141
|
const js = fresh(), shell = fresh();
|
|
109
142
|
stageNpm(js);
|
|
@@ -127,4 +160,12 @@ test.skipIf(!npmPackages.every(pkg => existsSync(join(npmDownloadsDir, pkg.archi
|
|
|
127
160
|
const help = spawnSync(process.execPath, [join(js, "bin/buninu-browser"), "--headless", "--help"], { encoding: "utf8", timeout: 15000 });
|
|
128
161
|
expect(help.status).toBe(0);
|
|
129
162
|
expect(help.stdout).toContain("Usage");
|
|
163
|
+
copyFileSync(new URL("../initramfs/bin/buninu-land", import.meta.url), join(js, "bin/buninu-land"));
|
|
164
|
+
for (const args of [["--help"], ["--headless", "--help"], ["--version"]]) {
|
|
165
|
+
const landHelp = spawnSync(process.execPath, [join(js, "bin/buninu-land"), ...args], { encoding: "utf8", timeout: 15000 });
|
|
166
|
+
expect(landHelp.status).toBe(0);
|
|
167
|
+
expect(landHelp.stdout).toContain(args[0] === "--version" ? "0.1.2" : args[0] === "--headless" ? "Wayland compositor" : "Usage");
|
|
168
|
+
}
|
|
169
|
+
expect(existsSync(join(js, "usr/lib/node_modules/buninu-land/vendor/canvaskit/canvaskit.wasm"))).toBe(true);
|
|
170
|
+
expect(existsSync(join(js, "usr/lib/node_modules/buninu-land/LICENSES/hyprland.txt"))).toBe(true);
|
|
130
171
|
}, 30000);
|