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 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
- | `bunterm` | a graphical terminal on the framebuffer: Skia (CanvasKit) text with CJK, colour emoji, seamless box drawing, kitty graphics images; xterm.js's emulator core over Bun's built-in PTY | `bunterm --help` |
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 nine commands
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` follows the current `Bun.Archive` boundary: extraction
342
- restores directories and symbolic links, while hard links are skipped by Bun
343
- 1.4.3. Creation stores regular files but not Unix metadata, links, or empty
344
- directories, and listing reports regular files only. It supports gzip and
345
- Bun's built-in zstd, not xz or bzip2.
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. Common examples:
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. The shared pieces are:
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 starts `node:repl`.
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. Terminal
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
- - Mouse, selection and scrollback viewing in `bunterm`.
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
@@ -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 { basename, join, resolve } from "node:path";
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 verboseListing = (name, file) => {
98
- const modified = Number.isFinite(file.lastModified) && file.lastModified > 0
99
- ? new Date(file.lastModified).toISOString().replace("T", " ").slice(0, 16)
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
- return `${String(file.size).padStart(12)} ${modified} ${name}`;
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
- try {
114
- const files = await archive.files(options.operands.length ? patterns() : undefined);
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 files = await archive.files(options.operands.length ? patterns() : undefined);
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
  }