buninu-linux 0.4.0 → 0.5.1
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/README.md +55 -10
- package/fetch-alpine.sh +6 -0
- package/graphics.md +7 -2
- package/initramfs/bin/bunterm +5 -1
- package/initramfs/bin/chroot +213 -0
- package/initramfs/bin/stripansi +27 -0
- package/initramfs/bin/tar +189 -14
- package/initramfs/init.js +74 -1
- package/initramfs/lib/bunterm/mouse.js +222 -0
- package/initramfs/lib/bunterm/renderer.js +48 -3
- package/initramfs/lib/bunterm/terminal.js +123 -2
- package/initramfs/lib/dlopen.js +4 -0
- package/initramfs/lib/document.js +5 -1
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +31 -2
- package/initramfs/usr/share/doc/buninu-linux/chroot.md +105 -0
- package/initramfs/usr/share/doc/buninu-linux/tar.md +30 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
* IP addresses and routes are configured manually because the image does not yet include a DHCP client.
|
|
38
38
|
* The bundled `jmi` editor, jsmdcui editor/terminal multitasking, and local JavaScript execution also work.
|
|
39
39
|
* The graphical terminal `bunterm` draws on the framebuffer with CJK, colour emoji and kitty images; see [Showing images](#showing-images)
|
|
40
|
+
+ Also has mouse click, wheel, and cursor drawing
|
|
40
41
|
|
|
41
42
|
---
|
|
42
43
|
|
|
@@ -310,6 +311,9 @@ buninu-help
|
|
|
310
311
|
# Markdown view with its images in place
|
|
311
312
|
# cfg.net first to show that GitHub image
|
|
312
313
|
jsmdcui --allow-url README.md
|
|
314
|
+
|
|
315
|
+
# The mouse works in bunterm; --no-mouse leaves /dev/input alone
|
|
316
|
+
bunterm -e jsmdcui --allow-url README.md
|
|
313
317
|
```
|
|
314
318
|
|
|
315
319
|
`bunterm` draws with Skia (CanvasKit) and understands the kitty graphics
|
|
@@ -333,18 +337,24 @@ needs a kernel with a framebuffer: build with `--linux-lts` or `--real`
|
|
|
333
337
|
| `poweroff` | sync pending writes and power off through the Linux reboot system call | `poweroff --help` |
|
|
334
338
|
| `reboot` | sync pending writes and restart through the shared Linux reboot logic | `reboot --help` |
|
|
335
339
|
| `tar` | create, extract, or list tar archives with gzip and zstd compression through `Bun.Archive` | `tar --help` |
|
|
336
|
-
| `
|
|
340
|
+
| `stripansi` | remove ANSI escape sequences from stdin, `-`, or one or more files and concatenate the results | — |
|
|
341
|
+
| `chroot` | `chroot(2)` into another root directory, mounting `/proc`, `/sys`, `/dev`, `/dev/pts`, `/run`, `/tmp` and `/etc/resolv.conf` for you and unmounting them afterwards | `chroot --help` |
|
|
342
|
+
| `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` |
|
|
337
343
|
|
|
338
|
-
Besides `bun` (and `sh`/`node` pointing at it), `/bin` includes
|
|
344
|
+
Besides `bun` (and `sh`/`node` pointing at it), `/bin` includes eleven commands
|
|
339
345
|
implemented as Bun scripts.
|
|
340
346
|
|
|
341
|
-
The small `tar`
|
|
342
|
-
restores directories
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
347
|
+
The small `tar` reads the archive's own headers for links and listing, so
|
|
348
|
+
extraction restores directories, hard links and every symbolic link —
|
|
349
|
+
including the absolute targets `Bun.Archive` refuses, which is what makes an
|
|
350
|
+
unpacked Alpine root filesystem usable — and `-t` lists every member, not
|
|
351
|
+
only regular files. Creation still follows the `Bun.Archive` boundary: it
|
|
352
|
+
stores regular files but not Unix metadata, links, or empty directories. It
|
|
353
|
+
supports gzip and Bun's built-in zstd, not xz or bzip2.
|
|
346
354
|
|
|
347
|
-
`--help` renders each Markdown manual in the terminal.
|
|
355
|
+
`--help` renders each Markdown manual in the terminal. The `bun x` examples
|
|
356
|
+
below require network access: `bun x` downloads the requested program from the
|
|
357
|
+
npm registry and runs it without installing it permanently. Common examples:
|
|
348
358
|
|
|
349
359
|
```sh
|
|
350
360
|
mount /dev/sda1 /mnt --mkdir
|
|
@@ -354,6 +364,34 @@ mount -t ntfs3 -o force /dev/sda3 /mnt/windows
|
|
|
354
364
|
umount /mnt
|
|
355
365
|
umount -R /mnt
|
|
356
366
|
|
|
367
|
+
|
|
368
|
+
# Clone a Git repository through bunproot
|
|
369
|
+
bun x bunproot --git clone https://github.com/jjtseng93/bunproot
|
|
370
|
+
|
|
371
|
+
# Read bunproot's Git manual without ANSI formatting in jmi
|
|
372
|
+
bunterm
|
|
373
|
+
bun x bunproot --git --readme | stripansi | jmi
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# Download and enter an x64 Alpine minirootfs
|
|
378
|
+
bun x bunproot --download-alpine-x64
|
|
379
|
+
mkdir alpine
|
|
380
|
+
cd alpine
|
|
381
|
+
tar xvf ../alpine-minirootfs-*-x86_64.tar.gz
|
|
382
|
+
cp $(which bun) bin
|
|
383
|
+
chroot .
|
|
384
|
+
apk add libgcc libstdc++
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Enter a root filesystem on a disk. /proc, /sys, /dev, /dev/pts, /run, /tmp
|
|
388
|
+
# and /etc/resolv.conf are mounted for you and removed again on exit
|
|
389
|
+
chroot /mnt
|
|
390
|
+
chroot /mnt /bin/bun --version
|
|
391
|
+
chroot -n /mnt /bin/sh # traditional: mount nothing
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
|
|
357
395
|
ip link set eth0 up
|
|
358
396
|
ip addr add 192.168.1.50/24 dev eth0
|
|
359
397
|
ip route add default via 192.168.1.1
|
|
@@ -730,7 +768,8 @@ Two things to expect:
|
|
|
730
768
|
|
|
731
769
|
The `/bin` commands described earlier are Bun scripts built on two shared
|
|
732
770
|
modules in `/lib`. `--help` uses `Bun.markdown.ansi` to render the Markdown
|
|
733
|
-
manual with hyperlinks
|
|
771
|
+
manual with hyperlinks, and prints the page's absolute path at the end so it
|
|
772
|
+
can be read or edited directly. The shared pieces are:
|
|
734
773
|
|
|
735
774
|
* `/lib/dlopen.js` — one `bun:ffi` binding to `/lib/libc.musl-x86_64.so.1`
|
|
736
775
|
(`mount`, `umount2`, `ioctl`, `socket`, `sync`, `reboot`, `syscall`, …), `errno`/`strerror`,
|
|
@@ -812,7 +851,13 @@ was found.
|
|
|
812
851
|
It installs signal handlers, loads a separate physical copy of musl through
|
|
813
852
|
`bun:ffi`, mounts `/proc`, `/sys`, `/dev`, `/tmp` and `/dev/pts` (without
|
|
814
853
|
the devpts mount every pty open fails with `ENODEV`, since `/dev/ptmx`
|
|
815
|
-
resolves through `/dev/pts/ptmx`), prints a greeting, and
|
|
854
|
+
resolves through `/dev/pts/ptmx`), turns Num Lock on, prints a greeting, and
|
|
855
|
+
starts `node:repl`. The kernel starts every virtual console with Num Lock
|
|
856
|
+
off, so the keypad would type arrows instead of digits; init sets it through
|
|
857
|
+
`KDSKBLED` on each console that exists, as the current state and as the state
|
|
858
|
+
a console is reset to. A console first activated later — `Ctrl-Alt-F3` on a
|
|
859
|
+
fresh boot — starts from the kernel default again, and `numlock()` in the REPL
|
|
860
|
+
(or `numlock(false)`) applies the setting to every console again.
|
|
816
861
|
Submitting an empty or whitespace-only REPL line prints the welcome message
|
|
817
862
|
and the available `cfg` getters again. The loader and libc paths
|
|
818
863
|
contain identical bytes but are deliberately distinct inodes:
|
package/fetch-alpine.sh
CHANGED
|
@@ -66,6 +66,12 @@ for module in failover net_failover virtio_net; do select_module "$module"; done
|
|
|
66
66
|
for module in virtio_blk ext4 ext2 vfat exfat ntfs3 nls_utf8 nls_cp437 nls_iso8859-1; do
|
|
67
67
|
select_module "$module"
|
|
68
68
|
done
|
|
69
|
+
# Pointing devices for `bunterm --mouse`, which reads /dev/input/event*.
|
|
70
|
+
# evdev is the character-device interface to the input layer; psmouse drives
|
|
71
|
+
# the PS/2 mouse every PC and QEMU's q35 machine has. A USB mouse needs the
|
|
72
|
+
# usbhid stack, which only the --real image carries. init.js loads these two
|
|
73
|
+
# at boot; nothing reads a pointer unless bunterm is asked to.
|
|
74
|
+
for module in evdev psmouse; do select_module "$module"; done
|
|
69
75
|
|
|
70
76
|
if [ "${REAL_MACHINE:-}" = 1 ]; then
|
|
71
77
|
# USB keyboard for the console; common storage paths (SATA/AHCI HDD and
|
package/graphics.md
CHANGED
|
@@ -19,6 +19,7 @@ via Bun FFI.
|
|
|
19
19
|
│ @xterm/headless Terminal ─ VT parser, screen buffer, attributes │
|
|
20
20
|
│ images.js ─ kitty placements as Skia images anchored to lines │
|
|
21
21
|
│ input.js ─ /dev/ttyN raw mode, console→xterm key sequences │
|
|
22
|
+
│ mouse.js ─ /dev/input/event* pointer, unless --no-mouse │
|
|
22
23
|
│ renderer.js ─ cells → glyphs (fonts) or vectors (glyphs.js) │
|
|
23
24
|
└───────────────────────────────────────────────┬──────────────────────┘
|
|
24
25
|
│ Skia draw calls
|
|
@@ -130,6 +131,7 @@ Around those, `/lib/bunterm/` adds:
|
|
|
130
131
|
| `glyphs.js` | Box drawing (U+2500–257F incl. double, dashed, rounded, mixed-weight), block elements and shades (U+2580–259F), Powerline (U+E0A0–E0BF) and legacy computing (U+1FB70–1FB97) drawn as Skia paths, so they meet seamlessly at any size. The shape tables are xterm.js's `CustomGlyphs.ts` (MIT); coordinates snap to pixel centres for crisp 1-px lines. |
|
|
131
132
|
| `images.js` | Kitty placements: decodes PNG (`f=100`) with Skia or raw RGB/RGBA (`f=24/32`), with `Bun.Image` as a transcoder for other encodings; anchors each placement to an xterm marker so it scrolls with its line and disappears with it (on the alternate screen, where xterm.js issues no markers, to a fixed row that is dropped when that screen ends); sizes in cells (`c,r`) or from pixel size; crops (`x,y,w,h`), cell offsets (`X,Y`), `p=` placement ids, `a=d` deletion scopes, and the cursor move after a placement. |
|
|
132
133
|
| `input.js` | Raw mode on the console (`TCGETS`/`TCSETS` through the shared libc binding) and translation of the kernel's "linux" key sequences to xterm's, honouring DECCKM. |
|
|
134
|
+
| `mouse.js` | `/dev/input/event*` through the input layer: finds pointing devices by their sysfs capability bitmaps, decodes the 24-byte `input_event` records (relative deltas, absolute axes read with `EVIOCGABS`, buttons, wheel) and reports a pointer position per `EV_SYN`. Not loaded under `--no-mouse`; otherwise the session draws an arrow and hands each report to xterm.js's `CoreMouseService`, which decides what the program enabled and encodes it. A wheel notch the program is not asking for scrolls the viewport instead — `scrollLines()` on the normal screen, arrow keys on the alternate one. |
|
|
133
135
|
| `terminal.js` | `createSession()` wires PTY → parser → emulator → renderer with a coalesced ~60 Hz frame timer and a serialized output queue, so a kitty packet always sees the cursor position left by the text before it. `runTerminal()` adds `runGraphics()` and the keyboard. |
|
|
134
136
|
|
|
135
137
|
Manual: `bunterm --help` / `initramfs/usr/share/doc/buninu-linux/bunterm.md`.
|
|
@@ -150,7 +152,9 @@ Manual: `bunterm --help` / `initramfs/usr/share/doc/buninu-linux/bunterm.md`.
|
|
|
150
152
|
4. `screen.flush()` finishes Skia's work and copies the wasm-heap frame into
|
|
151
153
|
the `/dev/fb0` mapping.
|
|
152
154
|
|
|
153
|
-
Keyboard bytes travel the other way: console → `input.js` → PTY
|
|
155
|
+
Keyboard bytes travel the other way: console → `input.js` → PTY, and with
|
|
156
|
+
the mouse on, pointer reports go `/dev/input/event*` → `mouse.js` → the arrow on
|
|
157
|
+
screen and xterm.js's `CoreMouseService` → PTY. Terminal
|
|
154
158
|
replies (cursor position, device attributes, kitty acks) go from the
|
|
155
159
|
emulator straight to the PTY.
|
|
156
160
|
|
|
@@ -204,7 +208,8 @@ through its transmit / `a=p` / `a=d` sequence exactly as in the browser.
|
|
|
204
208
|
|
|
205
209
|
## Not done yet
|
|
206
210
|
|
|
207
|
-
-
|
|
211
|
+
- Selection and scrollback viewing in `bunterm` (the pointer itself works,
|
|
212
|
+
and `--no-mouse` turns it off).
|
|
208
213
|
- Kitty `z < 0` (images behind text), animation frames, Unicode
|
|
209
214
|
placeholders, and shared-memory transmission.
|
|
210
215
|
- Text run shaping with ligatures for single-code-point cells (each cell is
|
package/initramfs/bin/bunterm
CHANGED
|
@@ -6,7 +6,7 @@ import { runTerminal } from "../lib/bunterm/terminal.js";
|
|
|
6
6
|
|
|
7
7
|
const usage = () => {
|
|
8
8
|
console.error(`Usage:
|
|
9
|
-
bunterm [/dev/ttyN] [--font-size N] [--fb /dev/fb0] [-e command [argument...]]
|
|
9
|
+
bunterm [/dev/ttyN] [--font-size N] [--fb /dev/fb0] [--no-mouse] [-e command [argument...]]
|
|
10
10
|
bunterm -h | --help`);
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -46,6 +46,10 @@ const main = async () => {
|
|
|
46
46
|
options.device = valued("--fb");
|
|
47
47
|
} else if (argument === "--no-blink") {
|
|
48
48
|
options.cursorBlink = false;
|
|
49
|
+
} else if (argument === "--mouse") {
|
|
50
|
+
options.mouse = true;
|
|
51
|
+
} else if (argument === "--no-mouse") {
|
|
52
|
+
options.mouse = false;
|
|
49
53
|
} else if (argument.startsWith("/dev/")) {
|
|
50
54
|
options.console = argument;
|
|
51
55
|
} else {
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/bin/bun
|
|
2
|
+
// chroot — run a command inside another root directory. `chroot --help`
|
|
3
|
+
// renders /usr/share/doc/buninu-linux/chroot.md.
|
|
4
|
+
//
|
|
5
|
+
// A Bun script over the chroot(2) and mount(2) bindings in /lib/dlopen.js.
|
|
6
|
+
// Unlike chroot(8) it prepares the new root before entering it: /proc, /sys,
|
|
7
|
+
// a recursive bind of /dev, /dev/pts, tmpfs on /run and /tmp, and the host's
|
|
8
|
+
// /etc/resolv.conf, so `chroot /mnt/rootfs` lands in a usable system. Every
|
|
9
|
+
// mount it makes is undone when the command exits.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { libc, cString, ptr, errno, strerror, Errno } from "../lib/dlopen.js";
|
|
13
|
+
import { showDocument } from "../lib/document.js";
|
|
14
|
+
|
|
15
|
+
const MS = { RDONLY: 1n, NOSUID: 2n, NODEV: 4n, NOEXEC: 8n, BIND: 4096n, REC: 16384n, SLAVE: 1n << 19n };
|
|
16
|
+
const MNT_DETACH = 2;
|
|
17
|
+
|
|
18
|
+
const fail = (message, status = 125) => {
|
|
19
|
+
console.error(`chroot: ${message}`);
|
|
20
|
+
process.exit(status);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const usage = () => {
|
|
24
|
+
console.error(`Usage:
|
|
25
|
+
chroot NEWROOT [COMMAND [ARGUMENT...]]
|
|
26
|
+
chroot [-n] [-k] [-v] NEWROOT [COMMAND [ARGUMENT...]]
|
|
27
|
+
chroot -h | --help full manual (chroot.md)`);
|
|
28
|
+
process.exit(125);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------- arguments --
|
|
32
|
+
|
|
33
|
+
const options = { mount: true, keep: false, verbose: false };
|
|
34
|
+
const positional = [];
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
for (let index = 0; index < argv.length; index++) {
|
|
37
|
+
const argument = argv[index];
|
|
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
|
+
}
|
|
47
|
+
if (positional.length === 0) usage();
|
|
48
|
+
|
|
49
|
+
const [target, ...command] = positional;
|
|
50
|
+
let root;
|
|
51
|
+
try {
|
|
52
|
+
root = realpathSync(target);
|
|
53
|
+
if (!statSync(root).isDirectory()) fail(`cannot change root directory to ${target}: not a directory`);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
fail(`cannot change root directory to ${target}: ${error.code === "ENOENT" ? "no such directory" : error.message}`);
|
|
56
|
+
}
|
|
57
|
+
if (root === "/") fail("refusing to chroot to /");
|
|
58
|
+
|
|
59
|
+
const report = (message) => { if (options.verbose) console.error(`chroot: ${message}`); };
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------- mounts --
|
|
62
|
+
|
|
63
|
+
const mountPoints = () => {
|
|
64
|
+
try {
|
|
65
|
+
return readFileSync("/proc/self/mounts", "utf8").split("\n").filter(Boolean).map((line) =>
|
|
66
|
+
line.split(" ")[1].replace(/\\([0-7]{3})/g, (_, code) => String.fromCharCode(parseInt(code, 8))));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const mountSyscall = (source, point, type, flags, data = null) =>
|
|
73
|
+
libc.symbols.mount(ptr(cString(source)), ptr(cString(point)), ptr(cString(type)), flags,
|
|
74
|
+
data === null ? null : ptr(cString(data)));
|
|
75
|
+
|
|
76
|
+
// Mount points this command made, innermost last, as paths inside the new
|
|
77
|
+
// root; they are undone in reverse order when the command is done.
|
|
78
|
+
const made = [];
|
|
79
|
+
|
|
80
|
+
// Prepares one mount. `inside` is the path within the new root. A target that
|
|
81
|
+
// is already a mount point, or that cannot be created on a read-only root, is
|
|
82
|
+
// left alone rather than treated as a failure: the command may still run.
|
|
83
|
+
const prepare = ({ inside, source, type, flags = 0n, data, file = false }) => {
|
|
84
|
+
const point = `${root}${inside}`;
|
|
85
|
+
if (mountPoints().includes(point)) {
|
|
86
|
+
report(`${inside} is already mounted`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!existsSync(point)) {
|
|
90
|
+
try {
|
|
91
|
+
if (file) writeFileSync(point, "");
|
|
92
|
+
else mkdirSync(point, { recursive: true, mode: 0o755 });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
report(`cannot create ${inside}: ${error.message}; skipped`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (mountSyscall(source, point, type, flags, data) === -1) {
|
|
99
|
+
report(`mount ${inside}: ${strerror(errno())}; skipped`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
made.push(inside);
|
|
103
|
+
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
|
+
}
|
|
122
|
+
}
|
|
123
|
+
prepare({ inside: "/run", source: "tmpfs", type: "tmpfs", flags: MS.NOSUID | MS.NODEV, data: "mode=755" });
|
|
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
|
+
prepare({ inside: "/etc/resolv.conf", source: "/etc/resolv.conf", type: "none", flags: MS.BIND, file: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Mount points are recorded by their path inside the new root. Until
|
|
133
|
+
// chroot(2) has happened they are reached through this prefix; afterwards
|
|
134
|
+
// that is how /proc/self/mounts names them too.
|
|
135
|
+
let prefix = root;
|
|
136
|
+
|
|
137
|
+
// Unmounts everything this command mounted, deepest first, including whatever
|
|
138
|
+
// appeared under a recursive bind. A busy mount is detached instead: the
|
|
139
|
+
// kernel finishes it once its last user is gone.
|
|
140
|
+
const cleanup = () => {
|
|
141
|
+
if (options.keep || made.length === 0) return;
|
|
142
|
+
const points = mountPoints();
|
|
143
|
+
const targets = new Set();
|
|
144
|
+
for (const inside of made) {
|
|
145
|
+
const point = `${prefix}${inside}`;
|
|
146
|
+
targets.add(point);
|
|
147
|
+
for (const below of points) if (below.startsWith(`${point}/`)) targets.add(below);
|
|
148
|
+
}
|
|
149
|
+
for (const point of [...targets].sort((a, b) => b.length - a.length)) {
|
|
150
|
+
if (libc.symbols.umount2(ptr(cString(point)), 0) === 0) {
|
|
151
|
+
report(`unmounted ${point}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const code = errno();
|
|
155
|
+
if (code === Errno.EINVAL) continue; // not mounted any more
|
|
156
|
+
if (libc.symbols.umount2(ptr(cString(point)), MNT_DETACH) === 0) {
|
|
157
|
+
report(`detached ${point} (was busy)`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
console.error(`chroot: unmount ${point}: ${strerror(errno())}`);
|
|
161
|
+
}
|
|
162
|
+
made.length = 0;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
process.on("exit", cleanup);
|
|
166
|
+
|
|
167
|
+
// ----------------------------------------------------------------- enter --
|
|
168
|
+
|
|
169
|
+
if (libc.symbols.chroot(ptr(cString(root))) === -1) {
|
|
170
|
+
const code = errno();
|
|
171
|
+
cleanup();
|
|
172
|
+
fail(`cannot change root directory to ${target}: ${code === Errno.EPERM ? "must be superuser" : strerror(code)}`);
|
|
173
|
+
}
|
|
174
|
+
prefix = "";
|
|
175
|
+
process.chdir("/");
|
|
176
|
+
|
|
177
|
+
const shell = process.env.SHELL && existsSync(process.env.SHELL) ? process.env.SHELL : "/bin/sh";
|
|
178
|
+
// chroot(8) runs an interactive shell when no command is given.
|
|
179
|
+
const run = command.length ? command : [shell, ...(process.stdin.isTTY ? ["-i"] : [])];
|
|
180
|
+
|
|
181
|
+
// The command owns the terminal while it runs: a Ctrl-C reaches it through
|
|
182
|
+
// the foreground process group, and this process stays alive to unmount.
|
|
183
|
+
const signalNumbers = {
|
|
184
|
+
SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5, SIGABRT: 6, SIGBUS: 7,
|
|
185
|
+
SIGFPE: 8, SIGKILL: 9, SIGUSR1: 10, SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13,
|
|
186
|
+
SIGALRM: 14, SIGTERM: 15,
|
|
187
|
+
};
|
|
188
|
+
const ignored = ["SIGINT", "SIGQUIT", "SIGTSTP"];
|
|
189
|
+
const ignore = () => {};
|
|
190
|
+
for (const signal of ignored) process.on(signal, ignore);
|
|
191
|
+
|
|
192
|
+
let status = 0;
|
|
193
|
+
try {
|
|
194
|
+
const child = Bun.spawn(run, {
|
|
195
|
+
cwd: "/",
|
|
196
|
+
env: { PATH: "/bin:/usr/bin:/sbin:/usr/sbin", ...process.env },
|
|
197
|
+
stdin: "inherit",
|
|
198
|
+
stdout: "inherit",
|
|
199
|
+
stderr: "inherit",
|
|
200
|
+
});
|
|
201
|
+
const forward = (signal) => { try { child.kill(signal); } catch {} };
|
|
202
|
+
for (const signal of ["SIGTERM", "SIGHUP"]) process.on(signal, forward);
|
|
203
|
+
await child.exited;
|
|
204
|
+
status = child.exitCode ?? (128 + (signalNumbers[child.signalCode] ?? 0));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error(`chroot: failed to run ${run[0]}: ${error.message}`);
|
|
207
|
+
status = error.code === "ENOENT" ? 127 : 126;
|
|
208
|
+
} finally {
|
|
209
|
+
for (const signal of ignored) process.off(signal, ignore);
|
|
210
|
+
cleanup();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
process.exit(status);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/bun
|
|
2
|
+
// stripansi — concatenate text inputs after removing ANSI escape sequences.
|
|
3
|
+
|
|
4
|
+
const operands = process.argv.slice(2);
|
|
5
|
+
const inputs = operands.length === 0
|
|
6
|
+
? ["-"]
|
|
7
|
+
: operands[0] === "--" ? operands.slice(1) : operands;
|
|
8
|
+
|
|
9
|
+
let stdinText;
|
|
10
|
+
let failed = false;
|
|
11
|
+
|
|
12
|
+
const readInput = async (operand) => {
|
|
13
|
+
if (operand !== "-") return Bun.file(operand).text();
|
|
14
|
+
stdinText ??= Bun.stdin.text();
|
|
15
|
+
return stdinText;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
for (const operand of inputs) {
|
|
19
|
+
try {
|
|
20
|
+
process.stdout.write(Bun.stripANSI(await readInput(operand)));
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error(`stripansi: ${operand}: ${error.message}`);
|
|
23
|
+
failed = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (failed) process.exitCode = 1;
|
package/initramfs/bin/tar
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// tar — a small tar/gzip/zstd CLI over Bun.Archive.
|
|
3
3
|
|
|
4
4
|
import { lstat, readdir } from "node:fs/promises";
|
|
5
|
-
import {
|
|
5
|
+
import { linkSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
7
|
import { showDocument } from "../lib/document.js";
|
|
7
8
|
|
|
8
9
|
const fail = (message) => {
|
|
@@ -89,16 +90,186 @@ const archiveBytes = async () => {
|
|
|
89
90
|
return zstd ? Bun.zstdDecompressSync(bytes) : bytes;
|
|
90
91
|
};
|
|
91
92
|
|
|
93
|
+
// ----------------------------------------------------------- tar headers --
|
|
94
|
+
//
|
|
95
|
+
// Bun.Archive extracts files, and symbolic links only when their target is
|
|
96
|
+
// relative and stays inside the extraction directory, which drops every
|
|
97
|
+
// absolute target (`/bin/busybox` — 306 of the 335 links in an Alpine
|
|
98
|
+
// minirootfs), every hard link, and every non-regular entry in
|
|
99
|
+
// `Archive.files()`. The header scan below reads the archive itself, so
|
|
100
|
+
// listing is complete and the links can be restored afterwards. Formats:
|
|
101
|
+
// POSIX ustar, GNU long names (`L`/`K`) and pax extended headers (`x`).
|
|
102
|
+
|
|
103
|
+
const BLOCK = 512;
|
|
104
|
+
const decoder = new TextDecoder();
|
|
105
|
+
|
|
106
|
+
const text = (block, offset, length) => {
|
|
107
|
+
let end = offset;
|
|
108
|
+
const stop = offset + length;
|
|
109
|
+
while (end < stop && block[end] !== 0) end++;
|
|
110
|
+
return decoder.decode(block.subarray(offset, end));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const octal = (block, offset, length) => {
|
|
114
|
+
// GNU base-256 encoding for values that do not fit the octal field.
|
|
115
|
+
if (block[offset] & 0x80) {
|
|
116
|
+
let value = block[offset] & 0x7f;
|
|
117
|
+
for (let index = offset + 1; index < offset + length; index++) value = value * 256 + block[index];
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
const digits = text(block, offset, length).trim();
|
|
121
|
+
const value = Number.parseInt(digits, 8);
|
|
122
|
+
return Number.isFinite(value) ? value : 0;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// The header checksum is computed with its own field read as spaces. A
|
|
126
|
+
// mismatch means this is not a tar header, so scanning stops there.
|
|
127
|
+
const checksumMatches = (block) => {
|
|
128
|
+
let sum = 0;
|
|
129
|
+
for (let index = 0; index < BLOCK; index++) {
|
|
130
|
+
sum += index >= 148 && index < 156 ? 0x20 : block[index];
|
|
131
|
+
}
|
|
132
|
+
return sum === octal(block, 148, 8);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const paxRecords = (data) => {
|
|
136
|
+
const records = {};
|
|
137
|
+
let offset = 0;
|
|
138
|
+
const content = decoder.decode(data);
|
|
139
|
+
while (offset < content.length) {
|
|
140
|
+
const space = content.indexOf(" ", offset);
|
|
141
|
+
if (space === -1) break;
|
|
142
|
+
const length = Number.parseInt(content.slice(offset, space), 10);
|
|
143
|
+
if (!Number.isFinite(length) || length <= 0) break;
|
|
144
|
+
const record = content.slice(space + 1, offset + length).replace(/\n$/, "");
|
|
145
|
+
const equals = record.indexOf("=");
|
|
146
|
+
if (equals > 0) records[record.slice(0, equals)] = record.slice(equals + 1);
|
|
147
|
+
offset += length;
|
|
148
|
+
}
|
|
149
|
+
return records;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const entryKinds = {
|
|
153
|
+
"0": "file", "\0": "file", "1": "hardlink", "2": "symlink", "3": "character",
|
|
154
|
+
"4": "block", "5": "directory", "6": "fifo", "7": "file",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Every member of the archive, in order, as { name, kind, target, size, mode,
|
|
158
|
+
// mtime }. Returns null when the bytes do not parse as tar.
|
|
159
|
+
const scanEntries = (bytes) => {
|
|
160
|
+
const entries = [];
|
|
161
|
+
let offset = 0;
|
|
162
|
+
let longName = null;
|
|
163
|
+
let longTarget = null;
|
|
164
|
+
let extended = {};
|
|
165
|
+
while (offset + BLOCK <= bytes.length) {
|
|
166
|
+
const block = bytes.subarray(offset, offset + BLOCK);
|
|
167
|
+
offset += BLOCK;
|
|
168
|
+
if (block.every((byte) => byte === 0)) break; // end-of-archive blocks
|
|
169
|
+
if (!checksumMatches(block)) return entries.length ? entries : null;
|
|
170
|
+
const flag = String.fromCharCode(block[156] || 0x30);
|
|
171
|
+
const size = extended.size !== undefined ? Number(extended.size) : octal(block, 124, 12);
|
|
172
|
+
const dataBlocks = Math.ceil(size / BLOCK) * BLOCK;
|
|
173
|
+
const data = bytes.subarray(offset, offset + size);
|
|
174
|
+
if (flag === "L" || flag === "K") {
|
|
175
|
+
const value = decoder.decode(data).replace(/\0+$/, "");
|
|
176
|
+
if (flag === "L") longName = value; else longTarget = value;
|
|
177
|
+
offset += dataBlocks;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (flag === "x" || flag === "X") {
|
|
181
|
+
extended = { ...extended, ...paxRecords(data) };
|
|
182
|
+
offset += dataBlocks;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (flag === "g") {
|
|
186
|
+
offset += dataBlocks;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const prefix = text(block, 345, 155);
|
|
190
|
+
const stored = text(block, 0, 100);
|
|
191
|
+
const name = extended.path ?? longName ?? (prefix ? `${prefix}/${stored}` : stored);
|
|
192
|
+
const target = extended.linkpath ?? longTarget ?? text(block, 157, 100);
|
|
193
|
+
entries.push({
|
|
194
|
+
name: name.replace(/^\.\//, ""),
|
|
195
|
+
kind: entryKinds[flag] ?? "file",
|
|
196
|
+
target,
|
|
197
|
+
size,
|
|
198
|
+
mode: octal(block, 100, 8) & 0o7777,
|
|
199
|
+
mtime: octal(block, 136, 12) * 1000,
|
|
200
|
+
});
|
|
201
|
+
longName = null;
|
|
202
|
+
longTarget = null;
|
|
203
|
+
extended = {};
|
|
204
|
+
offset += dataBlocks;
|
|
205
|
+
}
|
|
206
|
+
return entries;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const tarBytes = (bytes) => {
|
|
210
|
+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) return Bun.gunzipSync(bytes);
|
|
211
|
+
if (bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f && bytes[3] === 0xfd) {
|
|
212
|
+
return Bun.zstdDecompressSync(bytes);
|
|
213
|
+
}
|
|
214
|
+
return bytes;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Where a member lands, following GNU tar: a leading `/` is stripped, and a
|
|
218
|
+
// name that climbs out with `..` is refused. A link's target is never
|
|
219
|
+
// examined — an absolute symbolic link is a normal thing for a root
|
|
220
|
+
// filesystem to contain — and since links are made after every file, nothing
|
|
221
|
+
// is ever written through one.
|
|
222
|
+
const memberName = (name) => {
|
|
223
|
+
const stripped = name.replace(/^\/+/, "");
|
|
224
|
+
const parts = stripped.split("/");
|
|
225
|
+
if (parts.includes("..")) return null;
|
|
226
|
+
return stripped || null;
|
|
227
|
+
};
|
|
228
|
+
|
|
92
229
|
const patterns = () => options.operands.flatMap((name) => {
|
|
93
230
|
const clean = name.replace(/^\.\//, "").replace(/\/$/, "");
|
|
94
231
|
return [clean, `${clean}/**`];
|
|
95
232
|
});
|
|
96
233
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
234
|
+
const kindMarks = { directory: "/", symlink: "@", fifo: "|", character: "#", block: "#" };
|
|
235
|
+
|
|
236
|
+
const verboseListing = (entry) => {
|
|
237
|
+
const modified = entry.mtime > 0
|
|
238
|
+
? new Date(entry.mtime).toISOString().replace("T", " ").slice(0, 16)
|
|
100
239
|
: "---- -- -- --:--";
|
|
101
|
-
|
|
240
|
+
const suffix = entry.kind === "symlink" ? ` -> ${entry.target}`
|
|
241
|
+
: entry.kind === "hardlink" ? ` link to ${entry.target}`
|
|
242
|
+
: "";
|
|
243
|
+
return `${String(entry.size).padStart(12)} ${modified} ${entry.name}${kindMarks[entry.kind] ?? ""}${suffix}`;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Creates the links Bun.Archive left out. Files are already in place, so a
|
|
247
|
+
// hard link finds its target and a symbolic link cannot be written through.
|
|
248
|
+
const restoreLinks = (entries) => {
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
if (entry.kind !== "symlink" && entry.kind !== "hardlink") continue;
|
|
251
|
+
const name = memberName(entry.name);
|
|
252
|
+
if (!name || !entry.target) continue;
|
|
253
|
+
const path = resolve(options.directory, name);
|
|
254
|
+
try {
|
|
255
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
256
|
+
try {
|
|
257
|
+
if (entry.kind === "symlink") symlinkSync(entry.target, path);
|
|
258
|
+
else linkSync(resolve(options.directory, memberName(entry.target) ?? entry.target), path);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error.code !== "EEXIST") throw error;
|
|
261
|
+
// GNU tar replaces what is in the way rather than following it.
|
|
262
|
+
rmSync(path, { force: true });
|
|
263
|
+
if (entry.kind === "symlink") symlinkSync(entry.target, path);
|
|
264
|
+
else linkSync(resolve(options.directory, memberName(entry.target) ?? entry.target), path);
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
// A hard link whose target was filtered out by a member pattern is
|
|
268
|
+
// expected; anything else is worth reporting without stopping.
|
|
269
|
+
if (entry.kind === "hardlink" && error.code === "ENOENT" && options.operands.length) continue;
|
|
270
|
+
console.error(`tar: ${entry.name}: ${error.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
102
273
|
};
|
|
103
274
|
|
|
104
275
|
if (options.operation === "x" || options.operation === "t") {
|
|
@@ -109,22 +280,26 @@ if (options.operation === "x" || options.operation === "t") {
|
|
|
109
280
|
fail(error?.message || String(error));
|
|
110
281
|
}
|
|
111
282
|
|
|
283
|
+
// The header scan is what makes listing and links complete; if the bytes
|
|
284
|
+
// do not parse as tar, fall back to what Bun.Archive reports.
|
|
285
|
+
const scanned = scanEntries(tarBytes(await archiveBytes()));
|
|
286
|
+
const wanted = options.operands.length ? patterns().map((pattern) => new Bun.Glob(pattern)) : null;
|
|
287
|
+
const members = scanned
|
|
288
|
+
? scanned.filter((entry) => !wanted || wanted.some((glob) => glob.match(entry.name)))
|
|
289
|
+
: [...(await archive.files(options.operands.length ? patterns() : undefined))]
|
|
290
|
+
.map(([name, file]) => ({ name, kind: "file", target: "", size: file.size, mode: 0o644, mtime: file.lastModified }));
|
|
291
|
+
|
|
112
292
|
if (options.operation === "t") {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for (const [name, file] of files) {
|
|
116
|
-
console.log(options.verbose ? verboseListing(name, file) : name);
|
|
117
|
-
}
|
|
118
|
-
} catch (error) {
|
|
119
|
-
fail(error?.message || String(error));
|
|
293
|
+
for (const entry of members) {
|
|
294
|
+
console.log(options.verbose ? verboseListing(entry) : entry.name);
|
|
120
295
|
}
|
|
121
296
|
} else {
|
|
122
297
|
try {
|
|
123
298
|
if (options.verbose) {
|
|
124
|
-
const
|
|
125
|
-
for (const name of files.keys()) console.log(name);
|
|
299
|
+
for (const entry of members) console.log(entry.name);
|
|
126
300
|
}
|
|
127
301
|
await archive.extract(options.directory, options.operands.length ? { glob: patterns() } : undefined);
|
|
302
|
+
restoreLinks(members);
|
|
128
303
|
} catch (error) {
|
|
129
304
|
fail(error?.message || String(error));
|
|
130
305
|
}
|