buninu-linux 0.7.5 → 0.8.55

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 (47) hide show
  1. package/Dockerfile +5 -2
  2. package/NOTICE.md +110 -49
  3. package/README.md +439 -144
  4. package/fetch-npm.sh +44 -0
  5. package/fetch-plus-build.sh +1 -0
  6. package/index.js +15 -13
  7. package/initramfs/bin/buninu-browser +24 -0
  8. package/initramfs/bin/jspinyin +12 -0
  9. package/initramfs/bin/jspulse +2 -0
  10. package/initramfs/bin/tar +40 -6
  11. package/initramfs/bin/top +1 -7
  12. package/initramfs/buninu/CHANGELOG.md +4 -0
  13. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +28 -0
  14. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  15. package/initramfs/buninu/apps/bunmsh/src/main.js +7 -5
  16. package/initramfs/buninu/apps/bunmsh/src/shell.js +584 -75
  17. package/initramfs/buninu/apps/bunmsh/test/shell1.test.js +64 -0
  18. package/initramfs/buninu/package.json +1 -1
  19. package/initramfs/init.js +5 -0
  20. package/initramfs/lib/browser-resources.js +25 -0
  21. package/initramfs/lib/tar-exclude.js +20 -0
  22. package/initramfs/lib/tar-strip.js +94 -0
  23. package/initramfs/lib/top-memory.js +12 -0
  24. package/initramfs/usr/share/doc/buninu-linux/tar.md +24 -1
  25. package/initramfs/usr/share/doc/buninu-linux/top.md +18 -0
  26. package/package.json +1 -1
  27. package/scripts/arch.js +5 -5
  28. package/scripts/arch.sh +5 -5
  29. package/scripts/fetch-npm.js +51 -0
  30. package/scripts/fetch.js +1 -0
  31. package/scripts/js-tools.js +3 -0
  32. package/scripts/npm-packages.md +18 -0
  33. package/scripts/pack-initramfs.sh +1 -0
  34. package/scripts/pack.js +2 -0
  35. package/scripts/stage-npm.js +57 -0
  36. package/scripts/stage-npm.sh +62 -0
  37. package/test/fetch-npm.test.js +92 -0
  38. package/test/host/Dockerfile +6 -4
  39. package/test/host/boot-test.exp +2 -2
  40. package/test/host/host-test.sh +2 -2
  41. package/test/host/in-container.sh +4 -4
  42. package/test/js-tools-selection.test.js +19 -0
  43. package/test/npm-launchers.test.js +59 -0
  44. package/test/stage-npm.test.js +130 -0
  45. package/test/tar-strip.test.js +218 -0
  46. package/test/top-memory.test.js +23 -0
  47. package/initramfs/buninu/apps/bunmsh/test/shell.test.js +0 -2092
@@ -0,0 +1,92 @@
1
+ import { afterAll, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+ import { download } from "../scripts/fetch.js";
7
+ import { fetchNpm, npmDownloadsDir, npmPackages, parseNpmPins } from "../scripts/fetch-npm.js";
8
+
9
+ const work = mkdtempSync(join(tmpdir(), "buninu-fetch-npm-"));
10
+ const body = Buffer.from("isolated archive-download fixture");
11
+ const sha256 = createHash("sha256").update(body).digest("hex");
12
+ let requests = 0;
13
+ const server = Bun.serve({
14
+ hostname: "127.0.0.1", port: 0,
15
+ fetch(request) {
16
+ requests++;
17
+ return new URL(request.url).pathname === "/missing"
18
+ ? new Response("missing", { status: 404 }) : new Response(body);
19
+ },
20
+ });
21
+ afterAll(() => { server.stop(true); rmSync(work, { recursive: true, force: true }); });
22
+ const url = `http://127.0.0.1:${server.port}/archive`;
23
+
24
+ test("four exact registry pins share an architecture-independent cache", () => {
25
+ expect(npmPackages.map(({ name, version }) => `${name}@${version}`)).toEqual([
26
+ "buninu-browser@0.2.2", "@drxiaozhi/casty@1.3.4",
27
+ "@drxiaozhi/jspulse@0.2.1", "@drxiaozhi/jspinyin@0.1.1",
28
+ ]);
29
+ expect(basename(npmDownloadsDir)).toBe("npm");
30
+ for (const pkg of npmPackages) {
31
+ expect(new URL(pkg.url).hostname).toBe("registry.npmjs.org");
32
+ expect(pkg.url).toContain(`-${pkg.version}.tgz`);
33
+ expect(pkg.sha256).toMatch(/^[a-f0-9]{64}$/);
34
+ }
35
+ });
36
+
37
+ test("fetchNpm only delegates pinned downloads, without extracting or executing packages", async () => {
38
+ const calls = [];
39
+ const result = await fetchNpm({ downloadsDir: work, downloadFile: async (cfg, url, archive, sha256) => {
40
+ calls.push({ cfg, url, archive, sha256 });
41
+ return join(cfg.downloadsDir, archive);
42
+ } });
43
+ expect(calls).toEqual(npmPackages.map(({ url, archive, sha256 }) => ({ cfg: { downloadsDir: work }, url, archive, sha256 })));
44
+ expect(result).toEqual(npmPackages.map(({ archive }) => join(work, archive)));
45
+ for (const path of result) expect(existsSync(path)).toBe(false);
46
+ });
47
+
48
+ test("rejects malformed pins, floating versions, unsafe cache names and duplicate archives", () => {
49
+ const block = `"example@1.0.0":\n - https://registry.npmjs.org/example/-/example-1.0.0.tgz\n - sha256:${sha256}`;
50
+ for (const [from, to] of [["@1.0.0", "@latest"], ["@1.0.0", "@^1.0.0"], ["example@", "../bad@"], ["registry.npmjs.org", "example.invalid"], [sha256, "bad"], ['"example@1.0.0"', "example@1.0.0"]]) {
51
+ expect(() => parseNpmPins(block.replace(from, to))).toThrow();
52
+ }
53
+ expect(() => parseNpmPins(`${block}\n\n${block}`)).toThrow();
54
+ expect(() => parseNpmPins(block.split("\n").slice(0, 2).join("\n"))).toThrow();
55
+ expect(() => parseNpmPins("")).toThrow();
56
+ expect(parseNpmPins(`# Comment\r\n\r\n${block.replaceAll("\n", "\r\n")}\r\n`)[0].archive).toBe("example-1.0.0.tgz");
57
+ });
58
+
59
+ test("the Markdown manifest is directly parseable as YAML", () => {
60
+ const text = readFileSync(new URL("../scripts/npm-packages.md", import.meta.url), "utf8");
61
+ const yaml = Bun.YAML.parse(text);
62
+ for (const pin of npmPackages) {
63
+ expect(yaml[`${pin.name}@${pin.version}`]).toEqual([pin.url, `sha256:${pin.sha256}`]);
64
+ }
65
+ });
66
+
67
+ test("valid cache is reused without a request; corrupt cache is repaired", async () => {
68
+ const archive = "cache.tgz";
69
+ const path = join(work, archive);
70
+ const before = requests;
71
+ await download({ downloadsDir: work }, url, archive, sha256);
72
+ expect(requests).toBe(before + 1);
73
+ expect(readFileSync(path).equals(body)).toBe(true);
74
+ await download({ downloadsDir: work }, url, archive, sha256);
75
+ expect(requests).toBe(before + 1);
76
+ writeFileSync(path, "corrupt");
77
+ await download({ downloadsDir: work }, url, archive, sha256);
78
+ expect(requests).toBe(before + 2);
79
+ expect(readFileSync(path).equals(body)).toBe(true);
80
+ expect(existsSync(path + ".part")).toBe(false);
81
+ });
82
+
83
+ test("hash mismatch rejects the download and never promotes the partial archive", async () => {
84
+ await expect(download({ downloadsDir: work }, url, "bad.tgz", "0".repeat(64))).rejects.toThrow("SHA-256");
85
+ expect(existsSync(join(work, "bad.tgz"))).toBe(false);
86
+ expect(existsSync(join(work, "bad.tgz.part"))).toBe(false);
87
+ });
88
+
89
+ test("HTTP failures do not create a final archive", async () => {
90
+ await expect(download({ downloadsDir: work }, `http://127.0.0.1:${server.port}/missing`, "missing.tgz", sha256)).rejects.toThrow("HTTP 404");
91
+ expect(existsSync(join(work, "missing.tgz"))).toBe(false);
92
+ });
@@ -1,11 +1,13 @@
1
- # The "ordinary Debian 13 machine" host-test.sh builds and boots the guests on:
2
- # the README's build packages for both guests, QEMU with OVMF and AAVMF, node
1
+ # The Debian-based Bun host that host-test.sh builds and boots the guests on:
2
+ # the README's build packages for both guests, QEMU with OVMF and AAVMF, Bun
3
3
  # to run index.js, git to clone the checkout and expect to drive the serial
4
4
  # console. Built for linux/amd64 or linux/arm64, whichever host is simulated.
5
- FROM debian:13-slim
5
+ FROM oven/bun:1.4.2
6
+ USER root
7
+ ENTRYPOINT []
6
8
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
9
  binutils-mingw-w64-x86-64 binutils-aarch64-linux-gnu cpio curl dosfstools fakeroot mtools parted unzip \
8
- ca-certificates git nodejs expect \
10
+ ca-certificates git expect \
9
11
  qemu-system-x86 ovmf qemu-system-arm qemu-efi-aarch64 \
10
12
  && rm -rf /var/lib/apt/lists/*
11
13
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/expect -f
2
- # Boots vda.img with `node ./index.js -r --arch <arch>` and drives the serial
2
+ # Boots vda.img with `bun ./index.js -r --arch <arch>` and drives the serial
3
3
  # console: the REPL, the Buninu shell, eth0, PID 1, a tmpfs mount, a fetch from
4
4
  # inside the guest and poweroff. Each step waits for the output it expects, so
5
5
  # a hang or a wrong answer fails with a message instead of racing ahead.
@@ -36,7 +36,7 @@ proc step {what line pattern} {
36
36
 
37
37
  cd $checkout
38
38
  set start [clock seconds]
39
- spawn node ./index.js -r --arch $arch
39
+ spawn bun ./index.js -r --arch $arch
40
40
 
41
41
  set timeout $boot_timeout
42
42
  expect {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # Builds and boots both guests on a fresh Debian 13 host of either CPU, in
2
+ # Builds and boots both guests on a fresh Debian-based Bun host of either CPU, in
3
3
  # Docker:
4
4
  #
5
5
  # test/host/host-test.sh [amd64|arm64]
@@ -8,7 +8,7 @@
8
8
  # emulated and takes a few times longer. The container (./Dockerfile) has the
9
9
  # README's build packages and QEMU. It clones the committed HEAD of this
10
10
  # checkout, so uncommitted changes to the project are not tested, then builds
11
- # with the host toolchain rather than --docker and boots each guest through
11
+ # with the default JS tools rather than nested --docker and boots each guest through
12
12
  # boot-test.exp. The exit status is 0 only when every step passed.
13
13
  #
14
14
  # GUESTS="aarch64" which guests to test (default: x86_64 aarch64)
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
- # Runs inside the container host-test.sh starts: an ordinary Debian 13 machine
2
+ # Runs inside the container host-test.sh starts: a Debian-based Bun environment
3
3
  # of the chosen CPU. Clones the committed HEAD of the checkout mounted at
4
- # /repo, builds each guest with the host toolchain (no --docker), boots it
4
+ # /repo, builds each guest with the default JS tools (no nested --docker), boots it
5
5
  # through boot-test.exp, and checks that the builds leave git status clean.
6
6
  set -euo pipefail
7
7
 
@@ -9,7 +9,7 @@ here=$(cd "$(dirname "$0")" && pwd)
9
9
  guests=${GUESTS:-x86_64 aarch64}
10
10
  kvm=no
11
11
  if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then kvm=yes; fi
12
- echo "### host: $(uname -sm), node $(node --version), /dev/kvm: $kvm"
12
+ echo "### host: $(uname -sm), bun $(bun --version), /dev/kvm: $kvm"
13
13
 
14
14
  git config --global --add safe.directory '*'
15
15
  git clone --quiet /repo /work
@@ -27,7 +27,7 @@ for guest in $guests; do
27
27
  echo "### $guest: fetch + build ${BUILD_FLAGS:-}"
28
28
  log=/tmp/build-$guest.log
29
29
  # BUILD_FLAGS is split on purpose: it holds flags such as --linux-lts.
30
- if node ./index.js -fb --arch "$guest" ${BUILD_FLAGS:-} > "$log" 2>&1; then
30
+ if bun ./index.js -fb --arch "$guest" ${BUILD_FLAGS:-} > "$log" 2>&1; then
31
31
  grep -E '^==>|^Fetched|^Built|^Packed|^note' "$log" || true
32
32
  else
33
33
  echo "### FAIL: $guest build; last lines:"
@@ -0,0 +1,19 @@
1
+ import { expect, test } from "bun:test";
2
+ import { jsToolsEnabled } from "../scripts/js-tools.js";
3
+
4
+ test("JS tools default on and only explicit 0/off/false disable them", () => {
5
+ for (const value of [null, "", "1", "on", "true", "yes", "no", "2", " "])
6
+ expect(jsToolsEnabled(value)).toBe(true);
7
+ for (const value of ["0", "off", "false", "OFF", "False", " false ", "\t0\n"])
8
+ expect(jsToolsEnabled(value)).toBe(false);
9
+ const previous = process.env.BUNINU_JS_TOOLS;
10
+ try {
11
+ delete process.env.BUNINU_JS_TOOLS;
12
+ expect(jsToolsEnabled()).toBe(true);
13
+ process.env.BUNINU_JS_TOOLS = "off";
14
+ expect(jsToolsEnabled()).toBe(false);
15
+ } finally {
16
+ if (previous === undefined) delete process.env.BUNINU_JS_TOOLS;
17
+ else process.env.BUNINU_JS_TOOLS = previous;
18
+ }
19
+ });
@@ -0,0 +1,59 @@
1
+ import { afterAll, expect, test } from "bun:test";
2
+ import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ const root = mkdtempSync(join(tmpdir(), "buninu-npm-launchers-"));
8
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
9
+ mkdirSync(join(root, "bin"));
10
+
11
+ for (const [name, entry] of [["jspulse", "jspulse.js"]]) {
12
+ test(`${name}: physical import launcher preserves arguments, cwd, environment and exit status`, () => {
13
+ const source = new URL(`../initramfs/bin/${name}`, import.meta.url);
14
+ expect(lstatSync(source).isFile()).toBe(true);
15
+ expect(lstatSync(source).mode & 0o111).toBe(0o111);
16
+ const launcher = join(root, "bin", name);
17
+ copyFileSync(source, launcher);
18
+ const pkg = join(root, "usr/lib/node_modules/@drxiaozhi", name);
19
+ mkdirSync(pkg, { recursive: true });
20
+ writeFileSync(join(pkg, entry), `
21
+ console.log(JSON.stringify({args: process.argv.slice(2), cwd: process.cwd(),
22
+ marker: process.env.BUNINU_LAUNCHER_TEST, dir: import.meta.dir}));
23
+ process.exit(17);
24
+ `);
25
+ const args = ["--help", "value with spaces", "--", "-p", "4714"];
26
+ const result = spawnSync(process.execPath, [launcher, ...args], {
27
+ cwd: root, env: { ...process.env, BUNINU_LAUNCHER_TEST: "preserved" }, encoding: "utf8",
28
+ });
29
+ expect(result.status).toBe(17);
30
+ expect(JSON.parse(result.stdout)).toEqual({ args, cwd: root, marker: "preserved", dir: pkg });
31
+ });
32
+ }
33
+
34
+ test("jspinyin bypasses upstream bun x and forwards arguments to system jsmdcui with im.md", () => {
35
+ const source = new URL("../initramfs/bin/jspinyin", import.meta.url);
36
+ expect(lstatSync(source).isFile()).toBe(true);
37
+ expect(lstatSync(source).mode & 0o111).toBe(0o111);
38
+ const launcher = join(root, "bin/jspinyin");
39
+ copyFileSync(source, launcher);
40
+ const pkg = join(root, "usr/lib/node_modules/@drxiaozhi/jspinyin");
41
+ mkdirSync(pkg, { recursive: true });
42
+ writeFileSync(join(pkg, "index.js"), "throw new Error('upstream launcher must not run');");
43
+ writeFileSync(join(pkg, "im.md"), "# input method");
44
+ const system = join(root, "buninu/apps/jsmdcui/src");
45
+ mkdirSync(system, { recursive: true });
46
+ writeFileSync(join(system, "index.js"), `
47
+ console.log(JSON.stringify({args: process.argv.slice(2), cwd: process.cwd(),
48
+ marker: process.env.BUNINU_LAUNCHER_TEST, dir: import.meta.dir}));
49
+ process.exit(23);
50
+ `);
51
+ const args = ["--remote-debugging-port=9222", "value with spaces"];
52
+ const result = spawnSync(process.execPath, [launcher, ...args], {
53
+ cwd: root, env: { ...process.env, BUNINU_LAUNCHER_TEST: "preserved" }, encoding: "utf8",
54
+ });
55
+ expect(result.status).toBe(23);
56
+ expect(JSON.parse(result.stdout)).toEqual({
57
+ args: ["--tui", ...args, join(pkg, "im.md")], cwd: root, marker: "preserved", dir: system,
58
+ });
59
+ });
@@ -0,0 +1,130 @@
1
+ import { afterAll, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import { stageNpm } from "../scripts/stage-npm.js";
8
+ import { npmPackages, npmDownloadsDir } from "../scripts/fetch-npm.js";
9
+ import { ensureBrowserResources } from "../initramfs/lib/browser-resources.js";
10
+
11
+ const work = mkdtempSync(join(tmpdir(), "buninu-stage-npm-"));
12
+ afterAll(() => rmSync(work, { recursive: true, force: true }));
13
+ let sequence = 0;
14
+ const fresh = () => { const dir = join(work, String(sequence++)); mkdirSync(dir); return dir; };
15
+
16
+ async function fixture() {
17
+ const downloadsDir = fresh();
18
+ const packages = [];
19
+ for (const name of ["buninu-browser", "@drxiaozhi/casty", "@drxiaozhi/jspulse", "@drxiaozhi/jspinyin"]) {
20
+ const bin = name.split("/").pop();
21
+ const data = await new Bun.Archive({
22
+ "package/package.json": JSON.stringify({ name, version: "1.0.0", type: "module", bin: { [bin]: "index.js" } }),
23
+ "package/index.js": "#!/usr/bin/env bun\n",
24
+ "package/LICENSE": "keep license",
25
+ "package/usr/README.md": "keep resource documentation",
26
+ "package/usr/lib/canvaskit/data": "only browser excludes this",
27
+ "package/usr/share/fonts/data": "only browser excludes this",
28
+ "package/lib/data": "keep data",
29
+ }, { compress: "gzip" }).bytes();
30
+ const archive = bin + ".tgz";
31
+ writeFileSync(join(downloadsDir, archive), data);
32
+ packages.push({ name, version: "1.0.0", archive, sha256: createHash("sha256").update(data).digest("hex") });
33
+ }
34
+ return { downloadsDir, packages };
35
+ }
36
+
37
+ test("stages standard scoped layout, manifest, bins and browser-only exclusion", async () => {
38
+ const cfg = await fixture(), root = fresh();
39
+ stageNpm(root, cfg);
40
+ const lib = join(root, "usr/lib");
41
+ expect(JSON.parse(readFileSync(join(lib, "package.json"))).dependencies)
42
+ .toEqual(Object.fromEntries(cfg.packages.map(p => [p.name, p.version])));
43
+ for (const pkg of cfg.packages) {
44
+ const dir = join(lib, "node_modules", pkg.name);
45
+ expect(readFileSync(join(dir, "LICENSE"), "utf8")).toBe("keep license");
46
+ expect(existsSync(join(dir, "package"))).toBe(false);
47
+ expect(readFileSync(join(dir, "usr/README.md"), "utf8")).toBe("keep resource documentation");
48
+ expect(existsSync(join(dir, "usr/lib/canvaskit"))).toBe(pkg.name !== "buninu-browser");
49
+ expect(existsSync(join(dir, "usr/share/fonts"))).toBe(pkg.name !== "buninu-browser");
50
+ expect(statSync(join(dir, "index.js")).mode & 0o111).toBe(0o111);
51
+ expect(readlinkSync(join(lib, "node_modules/.bin", pkg.name.split("/").pop())))
52
+ .toBe(`../${pkg.name}/index.js`);
53
+ }
54
+ expect(() => stageNpm(root, cfg)).toThrow("already exists");
55
+ });
56
+
57
+ test("rejects missing/corrupt cache before writing a staging destination", async () => {
58
+ const cfg = await fixture(), root = fresh();
59
+ writeFileSync(join(cfg.downloadsDir, cfg.packages[3].archive), "corrupt");
60
+ expect(() => stageNpm(root, cfg)).toThrow("SHA-256 mismatch");
61
+ expect(existsSync(join(root, "usr"))).toBe(false);
62
+ rmSync(join(cfg.downloadsDir, cfg.packages[3].archive));
63
+ expect(() => stageNpm(root, cfg)).toThrow("fetch stage");
64
+ });
65
+
66
+ test("resource links use distinct relative destinations, repeat safely and preserve npm resources", () => {
67
+ const root = fresh();
68
+ mkdirSync(join(root, "lib/canvaskit"), { recursive: true });
69
+ mkdirSync(join(root, "usr/share/fonts"), { recursive: true });
70
+ const browser = join(root, "usr/lib/node_modules/buninu-browser");
71
+ mkdirSync(browser, { recursive: true });
72
+ ensureBrowserResources(browser, root);
73
+ ensureBrowserResources(browser, root);
74
+ for (const [local, shared] of [["usr/lib/canvaskit", "lib/canvaskit"], ["usr/share/fonts", "usr/share/fonts"]]) {
75
+ const link = join(browser, local), target = readlinkSync(link);
76
+ expect(target.startsWith("/")).toBe(false);
77
+ expect(resolve(link, "..", target)).toBe(join(root, shared));
78
+ expect(statSync(link).isDirectory()).toBe(true);
79
+ }
80
+ const fonts = join(browser, "usr/share/fonts");
81
+ rmSync(fonts); mkdirSync(fonts); writeFileSync(join(fonts, "custom"), "preserved");
82
+ ensureBrowserResources(browser, root);
83
+ expect(readFileSync(join(fonts, "custom"), "utf8")).toBe("preserved");
84
+ });
85
+
86
+ test("distribution launcher ensures resources then forwards to local casty without bunx", async () => {
87
+ const cfg = await fixture(), root = fresh();
88
+ stageNpm(root, cfg);
89
+ const browser = join(root, "usr/lib/node_modules/buninu-browser");
90
+ mkdirSync(join(root, "lib/canvaskit"), { recursive: true });
91
+ mkdirSync(join(root, "usr/share/fonts"), { recursive: true });
92
+ mkdirSync(join(root, "bin"));
93
+ copyFileSync(new URL("../initramfs/bin/buninu-browser", import.meta.url), join(root, "bin/buninu-browser"));
94
+ copyFileSync(new URL("../initramfs/lib/browser-resources.js", import.meta.url), join(root, "lib/browser-resources.js"));
95
+ writeFileSync(join(browser, "index.js"), `
96
+ import { statSync } from 'node:fs';
97
+ if (!statSync(import.meta.dir + '/usr/lib/canvaskit').isDirectory()) throw Error('missing');
98
+ export const chooseFrontend = args => ({ frontend: 'casty', args: args[0] === '--casty' ? args.slice(1) : args });
99
+ `);
100
+ writeFileSync(join(root, "usr/lib/node_modules/@drxiaozhi/casty/index.js"),
101
+ "console.log(JSON.stringify({args:process.argv.slice(2),engine:process.env.CASTY_BROWSER}));");
102
+ const result = spawnSync(process.execPath, [join(root, "bin/buninu-browser"), "--casty", "https://example.test", "--", "--chromium"], { encoding: "utf8" });
103
+ expect(result.status).toBe(0);
104
+ expect(JSON.parse(result.stdout)).toEqual({ args: ["https://example.test", "--", "--chromium"], engine: join(browser, "buninu-browser.js") });
105
+ });
106
+
107
+ test.skipIf(!npmPackages.every(pkg => existsSync(join(npmDownloadsDir, pkg.archive))))("real cached pins: JS and shell staging agree; browser headless help starts", () => {
108
+ const js = fresh(), shell = fresh();
109
+ stageNpm(js);
110
+ const result = spawnSync("bash", [join(import.meta.dir, "../scripts/stage-npm.sh"), shell], { encoding: "utf8" });
111
+ expect(result.status).toBe(0);
112
+ expect(readFileSync(join(shell, "usr/lib/package.json"), "utf8")).toBe(readFileSync(join(js, "usr/lib/package.json"), "utf8"));
113
+ for (const root of [js, shell]) {
114
+ expect(existsSync(join(root, "usr/lib/node_modules/buninu-browser/usr/README.md"))).toBe(true);
115
+ expect(existsSync(join(root, "usr/lib/node_modules/buninu-browser/usr/lib/canvaskit"))).toBe(false);
116
+ expect(existsSync(join(root, "usr/lib/node_modules/buninu-browser/usr/share/fonts"))).toBe(false);
117
+ for (const pkg of npmPackages) {
118
+ const info = JSON.parse(readFileSync(join(root, "usr/lib/node_modules", pkg.name, "package.json")));
119
+ expect([info.name, info.version]).toEqual([pkg.name, pkg.version]);
120
+ }
121
+ }
122
+ mkdirSync(join(js, "lib")); mkdirSync(join(js, "usr/share")); mkdirSync(join(js, "bin"));
123
+ symlinkSync(resolve(import.meta.dir, "../initramfs/lib/canvaskit"), join(js, "lib/canvaskit"));
124
+ symlinkSync(resolve(import.meta.dir, "../initramfs/usr/share/fonts"), join(js, "usr/share/fonts"));
125
+ copyFileSync(new URL("../initramfs/bin/buninu-browser", import.meta.url), join(js, "bin/buninu-browser"));
126
+ copyFileSync(new URL("../initramfs/lib/browser-resources.js", import.meta.url), join(js, "lib/browser-resources.js"));
127
+ const help = spawnSync(process.execPath, [join(js, "bin/buninu-browser"), "--headless", "--help"], { encoding: "utf8", timeout: 15000 });
128
+ expect(help.status).toBe(0);
129
+ expect(help.stdout).toContain("Usage");
130
+ }, 30000);
@@ -0,0 +1,218 @@
1
+ import { afterAll, expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { strippedName } from "../initramfs/lib/tar-strip.js";
7
+ import { excludeMatcher } from "../initramfs/lib/tar-exclude.js";
8
+
9
+ const work = mkdtempSync(join(tmpdir(), "buninu-tar-strip-"));
10
+ const cli = join(import.meta.dir, "../initramfs/bin/tar");
11
+ afterAll(() => rmSync(work, { recursive: true, force: true }));
12
+ let sequence = 0;
13
+ const run = (args, input) => spawnSync(process.execPath, [cli, ...args], { input, encoding: "utf8" });
14
+
15
+ // Small ustar fixtures, including links and extension headers Bun.Archive
16
+ // cannot create. All extraction is confined to this test's private directory.
17
+ function tar(entries) {
18
+ const chunks = [];
19
+ for (const { name, data = "", type = "0", target = "" } of entries) {
20
+ const body = Buffer.from(data);
21
+ const header = Buffer.alloc(512);
22
+ header.write(name, 0, 100);
23
+ header.write("0000644\0", 100);
24
+ header.write("0000000\0", 108);
25
+ header.write("0000000\0", 116);
26
+ header.write(body.length.toString(8).padStart(11, "0") + "\0", 124);
27
+ header.write("00000000000\0", 136);
28
+ header.fill(32, 148, 156);
29
+ header.write(type, 156);
30
+ header.write(target, 157, 100);
31
+ header.write("ustar\0", 257);
32
+ header.write("00", 263);
33
+ const sum = header.reduce((a, b) => a + b, 0);
34
+ header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
35
+ chunks.push(header, body, Buffer.alloc((512 - body.length % 512) % 512));
36
+ }
37
+ return Buffer.concat([...chunks, Buffer.alloc(1024)]);
38
+ }
39
+
40
+ const payload = tar([
41
+ { name: "top.txt", data: "skip" },
42
+ { name: "package/", type: "5" },
43
+ { name: "package/empty/", type: "5" },
44
+ { name: "package/lib/a.txt", data: "hello" },
45
+ { name: "package/hard", type: "1", target: "package/lib/a.txt" },
46
+ { name: "package/sym", type: "2", target: "lib/a.txt" },
47
+ { name: "package/absolute", type: "2", target: "/bin/busybox" },
48
+ ]);
49
+
50
+ function extract(bytes, flags = ["--strip-components=1"], stdin = false) {
51
+ const dir = join(work, String(sequence++));
52
+ const archive = dir + ".tar";
53
+ writeFileSync(archive, bytes);
54
+ const result = run(["-xf", stdin ? "-" : archive, "-C", dir, ...flags], stdin ? bytes : undefined);
55
+ return { dir, archive, ...result };
56
+ }
57
+
58
+ test("exclude matches path boundaries, ancestors, dot paths and wildcard patterns", () => {
59
+ const excluded = excludeMatcher(["package/usr/", "*.map", ".wh.*"]);
60
+ for (const name of ["package/usr", "./package/usr/a/b", "package/lib/a.map", "package/.wh.foo"])
61
+ expect(excluded(name)).toBe(true);
62
+ for (const name of ["package/usr2/a", "package/user/a", "package/lib/a.js"])
63
+ expect(excluded(name)).toBe(false);
64
+ });
65
+
66
+ test("exclude works before strip, with zero strip, compression and stdin", () => {
67
+ const bytes = tar([
68
+ { name: "package/usr/deep/data", data: "excluded without a directory header" },
69
+ { name: "package/usr/link", type: "2", target: "/outside" },
70
+ { name: "package/keep[*].js", data: "keep literal name" },
71
+ { name: "package/usr2/data", data: "keep boundary" },
72
+ { name: "package/debug.map", data: "exclude repeated pattern" },
73
+ { name: "package/hard", type: "1", target: "package/usr/deep/data" },
74
+ ]);
75
+ for (const strip of [0, 1]) {
76
+ for (const input of [bytes, Bun.gzipSync(bytes), Bun.zstdCompressSync(bytes)]) {
77
+ const result = extract(input, [`--strip-components=${strip}`, "--exclude=package/usr", "--exclude", "*.map"], true);
78
+ expect(result.status).toBe(0);
79
+ const root = strip ? result.dir : join(result.dir, "package");
80
+ expect(readFileSync(join(root, "keep[*].js"), "utf8")).toBe("keep literal name");
81
+ expect(existsSync(join(root, "usr"))).toBe(false);
82
+ expect(existsSync(join(root, "hard"))).toBe(false);
83
+ expect(existsSync(join(root, "debug.map"))).toBe(false);
84
+ expect(existsSync(join(root, "usr2/data"))).toBe(true);
85
+ }
86
+ }
87
+ });
88
+
89
+ test("exclude handles listing, all excluded, member selection and missing arguments", () => {
90
+ const result = extract(payload, ["--exclude=*"]);
91
+ expect(result.status).toBe(0);
92
+ expect(existsSync(join(result.dir, "package"))).toBe(false);
93
+ const listed = run(["-tf", result.archive, "--exclude=package"]);
94
+ expect(listed.status).toBe(0);
95
+ expect(listed.stdout.trim()).toBe("top.txt");
96
+ const selected = extract(payload, ["--strip-components=1", "package/lib", "--exclude=package/lib"]);
97
+ expect(selected.status).toBe(0);
98
+ expect(existsSync(join(selected.dir, "lib"))).toBe(false);
99
+ expect(run(["-xf", "unused", "--exclude"]).status).toBe(2);
100
+ });
101
+
102
+ test("exclude skips creation subtrees and never overwrites excluded destination files", () => {
103
+ const source = extract(payload, []);
104
+ const created = join(work, "exclude-create.tar");
105
+ expect(run(["-cf", created, "-C", source.dir, "--exclude=package/lib", "package/lib", "top.txt"]).status).toBe(0);
106
+ expect(run(["-tf", created]).stdout.trim()).toBe("top.txt");
107
+ const dest = join(work, "exclude-existing");
108
+ mkdirSync(join(dest, "package/lib"), { recursive: true });
109
+ writeFileSync(join(dest, "package/lib/a.txt"), "original");
110
+ const result = run(["-xf", source.archive, "-C", dest, "--exclude=package/lib"]);
111
+ expect(result.status).toBe(0);
112
+ expect(readFileSync(join(dest, "package/lib/a.txt"), "utf8")).toBe("original");
113
+ expect(existsSync(join(dest, "package/hard"))).toBe(false);
114
+ });
115
+
116
+ test("strips npm prefix, preserves empty directories and both link types", () => {
117
+ const result = extract(payload);
118
+ expect(result.status).toBe(0);
119
+ expect(readFileSync(join(result.dir, "lib/a.txt"), "utf8")).toBe("hello");
120
+ expect(existsSync(join(result.dir, "top.txt"))).toBe(false);
121
+ expect(existsSync(join(result.dir, "package"))).toBe(false);
122
+ expect(statSync(join(result.dir, "empty")).isDirectory()).toBe(true);
123
+ expect(statSync(join(result.dir, "hard")).ino).toBe(statSync(join(result.dir, "lib/a.txt")).ino);
124
+ expect(readlinkSync(join(result.dir, "sym"))).toBe("lib/a.txt");
125
+ expect(readlinkSync(join(result.dir, "absolute"))).toBe("/bin/busybox");
126
+ });
127
+
128
+ test("space form, gzip/zstd, stdin, and member filtering before stripping", () => {
129
+ for (const bytes of [payload, Bun.gzipSync(payload), Bun.zstdCompressSync(payload)]) {
130
+ const result = extract(bytes, ["--strip-components", "2", "package/lib"], true);
131
+ expect(result.status).toBe(0);
132
+ expect(readFileSync(join(result.dir, "a.txt"), "utf8")).toBe("hello");
133
+ expect(existsSync(join(result.dir, "hard"))).toBe(false);
134
+ }
135
+ });
136
+
137
+ test("default and explicit zero preserve the original Bun.Archive layout", () => {
138
+ for (const flags of [[], ["--strip-components=0"]]) {
139
+ const result = extract(payload, flags);
140
+ expect(result.status).toBe(0);
141
+ expect(readFileSync(join(result.dir, "package/lib/a.txt"), "utf8")).toBe("hello");
142
+ expect(readFileSync(join(result.dir, "top.txt"), "utf8")).toBe("skip");
143
+ expect(readlinkSync(join(result.dir, "package/sym"))).toBe("lib/a.txt");
144
+ }
145
+ });
146
+
147
+ test("listing keeps original names and excessive stripping skips members", () => {
148
+ const result = extract(payload, ["--strip-components=99"]);
149
+ expect(result.status).toBe(0);
150
+ expect(existsSync(join(result.dir, "lib"))).toBe(false);
151
+ const listed = run(["-tf", result.archive, "--strip-components=1"]);
152
+ expect(listed.status).toBe(0);
153
+ expect(listed.stdout).toContain("package/lib/a.txt");
154
+ });
155
+
156
+ test("rejects malformed counts and missing arguments", () => {
157
+ for (const count of ["", "-1", "1.5", "abc", "9007199254740992"]) {
158
+ expect(run(["-xf", "unused", `--strip-components=${count}`]).status).toBe(2);
159
+ }
160
+ expect(run(["-xf", "unused", "--strip-components"]).status).toBe(2);
161
+ });
162
+
163
+ test("dot prefix counts, and GNU long names survive stripping", () => {
164
+ expect(strippedName("./package/a", 1)).toBe("package/a");
165
+ expect(strippedName("package//lib/a", 1)).toBe("lib/a");
166
+ const long = "x".repeat(120);
167
+ const result = extract(tar([
168
+ { name: "././@LongLink", type: "L", data: `package/${long}\0` },
169
+ { name: "placeholder", data: "long" },
170
+ { name: "./package/dot", data: "dot" },
171
+ ]));
172
+ expect(result.status).toBe(0);
173
+ expect(readFileSync(join(result.dir, long), "utf8")).toBe("long");
174
+ expect(readFileSync(join(result.dir, "package/dot"), "utf8")).toBe("dot");
175
+ });
176
+
177
+ test("refuses traversal before stripping and unsafe hardlink targets", () => {
178
+ for (const entries of [
179
+ [{ name: "../escape", data: "bad" }],
180
+ [{ name: "package/../escape", data: "bad" }],
181
+ [{ name: "package/hard", type: "1", target: "../outside" }],
182
+ ]) expect(extract(tar(entries)).status).toBe(2);
183
+ });
184
+
185
+ test("PAX names and forward hardlink chains use stripped destinations", () => {
186
+ const record = (key, value) => {
187
+ const tail = ` ${key}=${value}\n`;
188
+ let length = tail.length + 1;
189
+ while (String(length).length + tail.length !== length) length = String(length).length + tail.length;
190
+ return `${length}${tail}`;
191
+ };
192
+ const result = extract(tar([
193
+ { name: "package/first", type: "1", target: "package/second" },
194
+ { name: "package/second", type: "1", target: "package/actual" },
195
+ { name: "pax", type: "x", data: record("path", "package/actual") },
196
+ { name: "placeholder", data: "pax" },
197
+ ]));
198
+ expect(result.status).toBe(0);
199
+ expect(readFileSync(join(result.dir, "first"), "utf8")).toBe("pax");
200
+ expect(statSync(join(result.dir, "first")).ino).toBe(statSync(join(result.dir, "actual")).ino);
201
+ });
202
+
203
+ test("does not follow a pre-existing symlink parent or an archive symlink parent", () => {
204
+ const out = join(work, "symlink-output");
205
+ const outside = join(work, "outside");
206
+ mkdirSync(out); mkdirSync(outside);
207
+ symlinkSync(outside, join(out, "lib"));
208
+ const archive = join(work, "symlink.tar");
209
+ writeFileSync(archive, payload);
210
+ expect(run(["-xf", archive, "-C", out, "--strip-components=1"]).status).toBe(2);
211
+ expect(existsSync(join(outside, "a.txt"))).toBe(false);
212
+ const result = extract(tar([
213
+ { name: "package/link", type: "2", target: outside },
214
+ { name: "package/link/nested", type: "2", target: "anything" },
215
+ ]));
216
+ expect(result.status).toBe(2);
217
+ expect(existsSync(join(outside, "nested"))).toBe(false);
218
+ });
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "bun:test";
2
+ import { memoryText } from "../initramfs/lib/top-memory.js";
3
+
4
+ test("used follows MemAvailable rather than subtracting all cache", () => {
5
+ expect(memoryText({ MemTotal: 1024000, MemFree: 102400, MemAvailable: 307200,
6
+ Buffers: 10240, Cached: 512000, SReclaimable: 20480 }))
7
+ .toBe("MiB Mem : 1000.0 total, 100.0 free, 700.0 used, 530.0 buff/cache, 300.0 available");
8
+ });
9
+
10
+ test("zero available is a real value, not a missing field", () => {
11
+ expect(memoryText({ MemTotal: 1024000, MemFree: 102400, MemAvailable: 0 }))
12
+ .toContain("1000.0 used, 0.0 buff/cache, 0.0 available");
13
+ });
14
+
15
+ test("missing available uses free conservatively and labels available n/a", () => {
16
+ expect(memoryText({ MemTotal: 1024000, MemFree: 102400, Cached: 512000 }))
17
+ .toContain("900.0 used, 500.0 buff/cache, n/a available");
18
+ expect(memoryText({})).toBe("MiB Mem : 0.0 total, 0.0 free, 0.0 used, 0.0 buff/cache, n/a available");
19
+ });
20
+
21
+ test("used cannot become negative with an inconsistent snapshot", () => {
22
+ expect(memoryText({ MemTotal: 1024, MemAvailable: 2048 })).toContain("0.0 used");
23
+ });