buninu-linux 0.4.0 → 0.6.0

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.
Files changed (42) hide show
  1. package/Dockerfile +23 -0
  2. package/NOTICE.md +111 -63
  3. package/README.md +292 -58
  4. package/build-image.sh +9 -5
  5. package/build-uki.sh +44 -28
  6. package/fetch-alpine.sh +45 -31
  7. package/fetch-bun.sh +14 -21
  8. package/graphics.md +7 -2
  9. package/index.js +150 -35
  10. package/initramfs/bin/bunterm +5 -1
  11. package/initramfs/bin/chroot +216 -0
  12. package/initramfs/bin/ip +3 -2
  13. package/initramfs/bin/ps +3 -0
  14. package/initramfs/bin/stripansi +27 -0
  15. package/initramfs/bin/tar +198 -14
  16. package/initramfs/init.js +149 -38
  17. package/initramfs/lib/bunterm/images.js +17 -2
  18. package/initramfs/lib/bunterm/mouse.js +222 -0
  19. package/initramfs/lib/bunterm/renderer.js +48 -3
  20. package/initramfs/lib/bunterm/terminal.js +123 -2
  21. package/initramfs/lib/dlopen.js +4 -0
  22. package/initramfs/lib/document.js +5 -1
  23. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +35 -2
  24. package/initramfs/usr/share/doc/buninu-linux/chroot.md +105 -0
  25. package/initramfs/usr/share/doc/buninu-linux/tar.md +32 -6
  26. package/native/aarch64/lib/ld-musl-aarch64.so.1 +0 -0
  27. package/native/aarch64/lib/libc.musl-aarch64.so.1 +0 -0
  28. package/native/aarch64/lib/libgcc_s.so.1 +0 -0
  29. package/native/aarch64/lib/libstdc++.so.6 +0 -0
  30. package/package.json +2 -2
  31. package/run-qemu.sh +61 -12
  32. package/scripts/arch.sh +113 -0
  33. package/scripts/fetch.sh +5 -3
  34. package/scripts/pack-initramfs.sh +18 -4
  35. package/test/host/Dockerfile +13 -0
  36. package/test/host/boot-test.exp +70 -0
  37. package/test/host/host-test.sh +49 -0
  38. package/test/host/in-container.sh +58 -0
  39. /package/{initramfs → native/x86_64}/lib/ld-musl-x86_64.so.1 +0 -0
  40. /package/{initramfs → native/x86_64}/lib/libc.musl-x86_64.so.1 +0 -0
  41. /package/{initramfs → native/x86_64}/lib/libgcc_s.so.1 +0 -0
  42. /package/{initramfs → native/x86_64}/lib/libstdc++.so.6 +0 -0
@@ -0,0 +1,216 @@
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 { chmodSync, 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
+
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
+ }
134
+
135
+ // Mount points are recorded by their path inside the new root. Until
136
+ // chroot(2) has happened they are reached through this prefix; afterwards
137
+ // that is how /proc/self/mounts names them too.
138
+ let prefix = root;
139
+
140
+ // Unmounts everything this command mounted, deepest first, including whatever
141
+ // appeared under a recursive bind. A busy mount is detached instead: the
142
+ // kernel finishes it once its last user is gone.
143
+ const cleanup = () => {
144
+ if (options.keep || made.length === 0) return;
145
+ const points = mountPoints();
146
+ const targets = new Set();
147
+ for (const inside of made) {
148
+ const point = `${prefix}${inside}`;
149
+ targets.add(point);
150
+ for (const below of points) if (below.startsWith(`${point}/`)) targets.add(below);
151
+ }
152
+ for (const point of [...targets].sort((a, b) => b.length - a.length)) {
153
+ if (libc.symbols.umount2(ptr(cString(point)), 0) === 0) {
154
+ report(`unmounted ${point}`);
155
+ continue;
156
+ }
157
+ const code = errno();
158
+ if (code === Errno.EINVAL) continue; // not mounted any more
159
+ if (libc.symbols.umount2(ptr(cString(point)), MNT_DETACH) === 0) {
160
+ report(`detached ${point} (was busy)`);
161
+ continue;
162
+ }
163
+ console.error(`chroot: unmount ${point}: ${strerror(errno())}`);
164
+ }
165
+ made.length = 0;
166
+ };
167
+
168
+ process.on("exit", cleanup);
169
+
170
+ // ----------------------------------------------------------------- enter --
171
+
172
+ if (libc.symbols.chroot(ptr(cString(root))) === -1) {
173
+ const code = errno();
174
+ cleanup();
175
+ fail(`cannot change root directory to ${target}: ${code === Errno.EPERM ? "must be superuser" : strerror(code)}`);
176
+ }
177
+ prefix = "";
178
+ process.chdir("/");
179
+
180
+ const shell = process.env.SHELL && existsSync(process.env.SHELL) ? process.env.SHELL : "/bin/sh";
181
+ // chroot(8) runs an interactive shell when no command is given.
182
+ const run = command.length ? command : [shell, ...(process.stdin.isTTY ? ["-i"] : [])];
183
+
184
+ // The command owns the terminal while it runs: a Ctrl-C reaches it through
185
+ // the foreground process group, and this process stays alive to unmount.
186
+ const signalNumbers = {
187
+ SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5, SIGABRT: 6, SIGBUS: 7,
188
+ SIGFPE: 8, SIGKILL: 9, SIGUSR1: 10, SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13,
189
+ SIGALRM: 14, SIGTERM: 15,
190
+ };
191
+ const ignored = ["SIGINT", "SIGQUIT", "SIGTSTP"];
192
+ const ignore = () => {};
193
+ for (const signal of ignored) process.on(signal, ignore);
194
+
195
+ let status = 0;
196
+ try {
197
+ const child = Bun.spawn(run, {
198
+ cwd: "/",
199
+ env: { PATH: "/bin:/usr/bin:/sbin:/usr/sbin", ...process.env },
200
+ stdin: "inherit",
201
+ stdout: "inherit",
202
+ stderr: "inherit",
203
+ });
204
+ const forward = (signal) => { try { child.kill(signal); } catch {} };
205
+ for (const signal of ["SIGTERM", "SIGHUP"]) process.on(signal, forward);
206
+ await child.exited;
207
+ status = child.exitCode ?? (128 + (signalNumbers[child.signalCode] ?? 0));
208
+ } catch (error) {
209
+ console.error(`chroot: failed to run ${run[0]}: ${error.message}`);
210
+ status = error.code === "ENOENT" ? 127 : 126;
211
+ } finally {
212
+ for (const signal of ignored) process.off(signal, ignore);
213
+ cleanup();
214
+ }
215
+
216
+ process.exit(status);
package/initramfs/bin/ip CHANGED
@@ -498,8 +498,9 @@ const commandAddress = (words, options) => {
498
498
  });
499
499
  };
500
500
 
501
- // struct rtentry on x86_64: rt_dst @8, rt_gateway @24, rt_genmask @40 (each a
502
- // 16-byte sockaddr), rt_flags u16 @56, rt_metric i16 @80, rt_dev char* @88.
501
+ // struct rtentry on 64-bit Linux, x86_64 and aarch64 alike: rt_dst @8,
502
+ // rt_gateway @24, rt_genmask @40 (each a 16-byte sockaddr), rt_flags u16 @56,
503
+ // rt_metric i16 @80, rt_dev char* @88.
503
504
  const rtentry = ({ destination, prefix, gateway, device, metric }) => {
504
505
  const route = new Uint8Array(120);
505
506
  const routeView = view(route);
package/initramfs/bin/ps CHANGED
@@ -16,6 +16,9 @@ const ttyName = (number) => {
16
16
  const minor = (device & 0xff) | ((device >>> 12) & 0xfff00);
17
17
  if (major >= 136 && major <= 143) return `pts/${minor}`;
18
18
  if (major === 4 && minor < 64) return `tty${minor}`;
19
+ // Serial ports: the 8250 UARTs of a PC and the PL011 of QEMU's aarch64 virt.
20
+ if (major === 4) return `ttyS${minor - 64}`;
21
+ if (major === 204 && minor >= 64) return `ttyAMA${minor - 64}`;
19
22
  if (major === 5 && minor === 0) return "tty";
20
23
  return `${major}:${minor}`;
21
24
  };
@@ -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) => {
@@ -40,6 +41,14 @@ for (let index = 0; index < argv.length; index++) {
40
41
  }
41
42
  if (argument === "--zstd") { options.compression = "zstd"; continue; }
42
43
  if (argument === "--gzip") { options.compression = "gzip"; continue; }
44
+ // GNU tar compatibility used by js-udocker. Bun.Archive never restores
45
+ // ownership or permissions, and extraction replaces existing files, so
46
+ // these options already describe its normal extraction behaviour.
47
+ if (argument === "--no-same-owner" || argument === "--no-same-permissions" || argument === "--overwrite") continue;
48
+ // Bun.Archive does not extract device nodes. js-udocker separately removes
49
+ // whiteout files after extraction, so its excludes are safe no-ops here.
50
+ if (argument === "--exclude") { take(index++, argument); continue; }
51
+ if (argument.startsWith("--exclude=")) continue;
43
52
  if (argument === "-C" || argument === "--directory") {
44
53
  options.directory = take(index++, argument);
45
54
  continue;
@@ -66,6 +75,7 @@ for (let index = 0; index < argv.length; index++) {
66
75
  } else if (flag === "r") {
67
76
  fail("-r append is not supported by Bun.Archive");
68
77
  } else if (flag === "v") options.verbose = true;
78
+ else if (flag === "o") {} // GNU tar: --no-same-owner
69
79
  else if (flag === "z") options.compression = "gzip";
70
80
  else if (flag === "f") {
71
81
  const attached = flags.slice(offset + 1);
@@ -89,16 +99,186 @@ const archiveBytes = async () => {
89
99
  return zstd ? Bun.zstdDecompressSync(bytes) : bytes;
90
100
  };
91
101
 
102
+ // ----------------------------------------------------------- tar headers --
103
+ //
104
+ // Bun.Archive extracts files, and symbolic links only when their target is
105
+ // relative and stays inside the extraction directory, which drops every
106
+ // absolute target (`/bin/busybox` — 306 of the 335 links in an Alpine
107
+ // minirootfs), every hard link, and every non-regular entry in
108
+ // `Archive.files()`. The header scan below reads the archive itself, so
109
+ // listing is complete and the links can be restored afterwards. Formats:
110
+ // POSIX ustar, GNU long names (`L`/`K`) and pax extended headers (`x`).
111
+
112
+ const BLOCK = 512;
113
+ const decoder = new TextDecoder();
114
+
115
+ const text = (block, offset, length) => {
116
+ let end = offset;
117
+ const stop = offset + length;
118
+ while (end < stop && block[end] !== 0) end++;
119
+ return decoder.decode(block.subarray(offset, end));
120
+ };
121
+
122
+ const octal = (block, offset, length) => {
123
+ // GNU base-256 encoding for values that do not fit the octal field.
124
+ if (block[offset] & 0x80) {
125
+ let value = block[offset] & 0x7f;
126
+ for (let index = offset + 1; index < offset + length; index++) value = value * 256 + block[index];
127
+ return value;
128
+ }
129
+ const digits = text(block, offset, length).trim();
130
+ const value = Number.parseInt(digits, 8);
131
+ return Number.isFinite(value) ? value : 0;
132
+ };
133
+
134
+ // The header checksum is computed with its own field read as spaces. A
135
+ // mismatch means this is not a tar header, so scanning stops there.
136
+ const checksumMatches = (block) => {
137
+ let sum = 0;
138
+ for (let index = 0; index < BLOCK; index++) {
139
+ sum += index >= 148 && index < 156 ? 0x20 : block[index];
140
+ }
141
+ return sum === octal(block, 148, 8);
142
+ };
143
+
144
+ const paxRecords = (data) => {
145
+ const records = {};
146
+ let offset = 0;
147
+ const content = decoder.decode(data);
148
+ while (offset < content.length) {
149
+ const space = content.indexOf(" ", offset);
150
+ if (space === -1) break;
151
+ const length = Number.parseInt(content.slice(offset, space), 10);
152
+ if (!Number.isFinite(length) || length <= 0) break;
153
+ const record = content.slice(space + 1, offset + length).replace(/\n$/, "");
154
+ const equals = record.indexOf("=");
155
+ if (equals > 0) records[record.slice(0, equals)] = record.slice(equals + 1);
156
+ offset += length;
157
+ }
158
+ return records;
159
+ };
160
+
161
+ const entryKinds = {
162
+ "0": "file", "\0": "file", "1": "hardlink", "2": "symlink", "3": "character",
163
+ "4": "block", "5": "directory", "6": "fifo", "7": "file",
164
+ };
165
+
166
+ // Every member of the archive, in order, as { name, kind, target, size, mode,
167
+ // mtime }. Returns null when the bytes do not parse as tar.
168
+ const scanEntries = (bytes) => {
169
+ const entries = [];
170
+ let offset = 0;
171
+ let longName = null;
172
+ let longTarget = null;
173
+ let extended = {};
174
+ while (offset + BLOCK <= bytes.length) {
175
+ const block = bytes.subarray(offset, offset + BLOCK);
176
+ offset += BLOCK;
177
+ if (block.every((byte) => byte === 0)) break; // end-of-archive blocks
178
+ if (!checksumMatches(block)) return entries.length ? entries : null;
179
+ const flag = String.fromCharCode(block[156] || 0x30);
180
+ const size = extended.size !== undefined ? Number(extended.size) : octal(block, 124, 12);
181
+ const dataBlocks = Math.ceil(size / BLOCK) * BLOCK;
182
+ const data = bytes.subarray(offset, offset + size);
183
+ if (flag === "L" || flag === "K") {
184
+ const value = decoder.decode(data).replace(/\0+$/, "");
185
+ if (flag === "L") longName = value; else longTarget = value;
186
+ offset += dataBlocks;
187
+ continue;
188
+ }
189
+ if (flag === "x" || flag === "X") {
190
+ extended = { ...extended, ...paxRecords(data) };
191
+ offset += dataBlocks;
192
+ continue;
193
+ }
194
+ if (flag === "g") {
195
+ offset += dataBlocks;
196
+ continue;
197
+ }
198
+ const prefix = text(block, 345, 155);
199
+ const stored = text(block, 0, 100);
200
+ const name = extended.path ?? longName ?? (prefix ? `${prefix}/${stored}` : stored);
201
+ const target = extended.linkpath ?? longTarget ?? text(block, 157, 100);
202
+ entries.push({
203
+ name: name.replace(/^\.\//, ""),
204
+ kind: entryKinds[flag] ?? "file",
205
+ target,
206
+ size,
207
+ mode: octal(block, 100, 8) & 0o7777,
208
+ mtime: octal(block, 136, 12) * 1000,
209
+ });
210
+ longName = null;
211
+ longTarget = null;
212
+ extended = {};
213
+ offset += dataBlocks;
214
+ }
215
+ return entries;
216
+ };
217
+
218
+ const tarBytes = (bytes) => {
219
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b) return Bun.gunzipSync(bytes);
220
+ if (bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f && bytes[3] === 0xfd) {
221
+ return Bun.zstdDecompressSync(bytes);
222
+ }
223
+ return bytes;
224
+ };
225
+
226
+ // Where a member lands, following GNU tar: a leading `/` is stripped, and a
227
+ // name that climbs out with `..` is refused. A link's target is never
228
+ // examined — an absolute symbolic link is a normal thing for a root
229
+ // filesystem to contain — and since links are made after every file, nothing
230
+ // is ever written through one.
231
+ const memberName = (name) => {
232
+ const stripped = name.replace(/^\/+/, "");
233
+ const parts = stripped.split("/");
234
+ if (parts.includes("..")) return null;
235
+ return stripped || null;
236
+ };
237
+
92
238
  const patterns = () => options.operands.flatMap((name) => {
93
239
  const clean = name.replace(/^\.\//, "").replace(/\/$/, "");
94
240
  return [clean, `${clean}/**`];
95
241
  });
96
242
 
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)
243
+ const kindMarks = { directory: "/", symlink: "@", fifo: "|", character: "#", block: "#" };
244
+
245
+ const verboseListing = (entry) => {
246
+ const modified = entry.mtime > 0
247
+ ? new Date(entry.mtime).toISOString().replace("T", " ").slice(0, 16)
100
248
  : "---- -- -- --:--";
101
- return `${String(file.size).padStart(12)} ${modified} ${name}`;
249
+ const suffix = entry.kind === "symlink" ? ` -> ${entry.target}`
250
+ : entry.kind === "hardlink" ? ` link to ${entry.target}`
251
+ : "";
252
+ return `${String(entry.size).padStart(12)} ${modified} ${entry.name}${kindMarks[entry.kind] ?? ""}${suffix}`;
253
+ };
254
+
255
+ // Creates the links Bun.Archive left out. Files are already in place, so a
256
+ // hard link finds its target and a symbolic link cannot be written through.
257
+ const restoreLinks = (entries) => {
258
+ for (const entry of entries) {
259
+ if (entry.kind !== "symlink" && entry.kind !== "hardlink") continue;
260
+ const name = memberName(entry.name);
261
+ if (!name || !entry.target) continue;
262
+ const path = resolve(options.directory, name);
263
+ try {
264
+ mkdirSync(dirname(path), { recursive: true });
265
+ try {
266
+ if (entry.kind === "symlink") symlinkSync(entry.target, path);
267
+ else linkSync(resolve(options.directory, memberName(entry.target) ?? entry.target), path);
268
+ } catch (error) {
269
+ if (error.code !== "EEXIST") throw error;
270
+ // GNU tar replaces what is in the way rather than following it.
271
+ rmSync(path, { force: true });
272
+ if (entry.kind === "symlink") symlinkSync(entry.target, path);
273
+ else linkSync(resolve(options.directory, memberName(entry.target) ?? entry.target), path);
274
+ }
275
+ } catch (error) {
276
+ // A hard link whose target was filtered out by a member pattern is
277
+ // expected; anything else is worth reporting without stopping.
278
+ if (entry.kind === "hardlink" && error.code === "ENOENT" && options.operands.length) continue;
279
+ console.error(`tar: ${entry.name}: ${error.message}`);
280
+ }
281
+ }
102
282
  };
103
283
 
104
284
  if (options.operation === "x" || options.operation === "t") {
@@ -109,22 +289,26 @@ if (options.operation === "x" || options.operation === "t") {
109
289
  fail(error?.message || String(error));
110
290
  }
111
291
 
292
+ // The header scan is what makes listing and links complete; if the bytes
293
+ // do not parse as tar, fall back to what Bun.Archive reports.
294
+ const scanned = scanEntries(tarBytes(await archiveBytes()));
295
+ const wanted = options.operands.length ? patterns().map((pattern) => new Bun.Glob(pattern)) : null;
296
+ const members = scanned
297
+ ? scanned.filter((entry) => !wanted || wanted.some((glob) => glob.match(entry.name)))
298
+ : [...(await archive.files(options.operands.length ? patterns() : undefined))]
299
+ .map(([name, file]) => ({ name, kind: "file", target: "", size: file.size, mode: 0o644, mtime: file.lastModified }));
300
+
112
301
  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));
302
+ for (const entry of members) {
303
+ console.log(options.verbose ? verboseListing(entry) : entry.name);
120
304
  }
121
305
  } else {
122
306
  try {
123
307
  if (options.verbose) {
124
- const files = await archive.files(options.operands.length ? patterns() : undefined);
125
- for (const name of files.keys()) console.log(name);
308
+ for (const entry of members) console.log(entry.name);
126
309
  }
127
310
  await archive.extract(options.directory, options.operands.length ? { glob: patterns() } : undefined);
311
+ restoreLinks(members);
128
312
  } catch (error) {
129
313
  fail(error?.message || String(error));
130
314
  }