buninu-linux 0.7.3 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +46 -46
- package/README.md +23 -5
- package/build-uki.sh +21 -7
- package/check_build.js +217 -0
- package/fetch-alpine.sh +3 -0
- package/fetch-bun.sh +3 -0
- package/index.js +72 -8
- package/initramfs/bin/dd +126 -0
- package/initramfs/bin/gunzip +6 -0
- package/initramfs/bin/gzip +6 -0
- package/initramfs/bin/mcopy +6 -0
- package/initramfs/bin/mdir +6 -0
- package/initramfs/bin/mkfs.fat +73 -0
- package/initramfs/bin/mmd +6 -0
- package/initramfs/bin/parted +178 -0
- package/initramfs/bin/truncate +78 -0
- package/initramfs/bin/unzip +248 -0
- package/initramfs/bin/zcat +6 -0
- package/initramfs/lib/crc32.js +16 -0
- package/initramfs/lib/fat.js +534 -0
- package/initramfs/lib/gpt.js +144 -0
- package/initramfs/lib/gzip.js +115 -0
- package/initramfs/lib/mtools.js +158 -0
- package/package.json +1 -1
- package/scripts/arch.js +156 -0
- package/scripts/arch.sh +5 -5
- package/scripts/build.js +145 -0
- package/scripts/cpio.js +107 -0
- package/scripts/fetch.js +194 -0
- package/scripts/modules.js +158 -0
- package/scripts/pack-initramfs.sh +33 -0
- package/scripts/pack.js +184 -0
- package/scripts/uki.js +136 -0
- package/test/js-tools.test.js +213 -0
package/scripts/pack.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The initramfs, as scripts/pack-initramfs.sh packs it with BUNINU_JS_CPIO=1:
|
|
2
|
+
// initramfs/ with native/<arch>/ laid over it, the README, licenses and
|
|
3
|
+
// os-release added, written by scripts/cpio.js to build/initramfs.cpio.gz.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/pack.js BUNINU_ARCH as in arch.js; SOURCE_DATE_EPOCH
|
|
6
|
+
// fixes every mtime, as cpio.js does
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
copyFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
lstatSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
readlinkSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
statSync,
|
|
21
|
+
symlinkSync,
|
|
22
|
+
utimesSync,
|
|
23
|
+
lutimesSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { config, jsRuntime, rootDir, runIfMain } from "./arch.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `cp -a SOURCE/. TARGET/`: modes, times and symlinks kept, directories
|
|
31
|
+
* merged into ones already there (and given the source's mode, as cp -a does).
|
|
32
|
+
*/
|
|
33
|
+
export const copyTree = (source, target) => {
|
|
34
|
+
const stat = lstatSync(source);
|
|
35
|
+
if (stat.isSymbolicLink()) {
|
|
36
|
+
rmSync(target, { force: true });
|
|
37
|
+
symlinkSync(readlinkSync(source), target);
|
|
38
|
+
lutimesSync(target, stat.atime, stat.mtime);
|
|
39
|
+
} else if (stat.isDirectory()) {
|
|
40
|
+
mkdirSync(target, { recursive: true });
|
|
41
|
+
for (const name of readdirSync(source)) copyTree(join(source, name), join(target, name));
|
|
42
|
+
chmodSync(target, stat.mode & 0o7777);
|
|
43
|
+
utimesSync(target, stat.atime, stat.mtime);
|
|
44
|
+
} else if (stat.isFile()) {
|
|
45
|
+
rmSync(target, { force: true });
|
|
46
|
+
copyFileSync(source, target);
|
|
47
|
+
chmodSync(target, stat.mode & 0o7777);
|
|
48
|
+
utimesSync(target, stat.atime, stat.mtime);
|
|
49
|
+
} else {
|
|
50
|
+
throw new Error(`${source}: not a file, directory or symlink`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The modes git would check out with umask 022: directories 755, files 755
|
|
56
|
+
* when their owner may execute them and 644 otherwise; symlinks untouched.
|
|
57
|
+
* A checkout made under another umask (native Termux's 0077 gives 600/700)
|
|
58
|
+
* then packs the same archive.
|
|
59
|
+
*/
|
|
60
|
+
export const normalizeModes = (path) => {
|
|
61
|
+
const stat = lstatSync(path);
|
|
62
|
+
if (stat.isSymbolicLink()) return;
|
|
63
|
+
if (stat.isDirectory()) {
|
|
64
|
+
chmodSync(path, 0o755);
|
|
65
|
+
for (const name of readdirSync(path)) normalizeModes(join(path, name));
|
|
66
|
+
} else if (stat.isFile()) {
|
|
67
|
+
chmodSync(path, stat.mode & 0o100 ? 0o755 : 0o644);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** `cp FILE TARGET`: contents and mode, a new mtime. */
|
|
72
|
+
const copyFile = (source, target) => {
|
|
73
|
+
rmSync(target, { force: true });
|
|
74
|
+
copyFileSync(source, target);
|
|
75
|
+
chmodSync(target, statSync(source).mode & 0o777 & ~process.umask());
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** `ln -sfn TARGET LINK` */
|
|
79
|
+
const link = (target, path) => {
|
|
80
|
+
rmSync(path, { force: true });
|
|
81
|
+
symlinkSync(target, path);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The device nodes of the archive, PATH:c:MAJOR:MINOR:MODE. Nothing has
|
|
86
|
+
* mounted devtmpfs when the kernel execs Bun as PID 1: console is init's fd
|
|
87
|
+
* 0/1/2 and JSC aborts without urandom; the rest keep /dev usable if the
|
|
88
|
+
* devtmpfs mount in init.js fails. The serial port is ttyS0 (4, 64) on
|
|
89
|
+
* x86_64 and the PL011 ttyAMA0 (204, 64) on aarch64.
|
|
90
|
+
*/
|
|
91
|
+
export const devices = (cfg) => [
|
|
92
|
+
"dev/console:c:5:1:0600",
|
|
93
|
+
`dev/${cfg.serialConsole}:c:${cfg.serialMajor}:64:0620`,
|
|
94
|
+
"dev/null:c:1:3:0666",
|
|
95
|
+
"dev/zero:c:1:5:0666",
|
|
96
|
+
"dev/random:c:1:8:0666",
|
|
97
|
+
"dev/urandom:c:1:9:0666",
|
|
98
|
+
"dev/tty:c:5:0:0666",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
export const osRelease = (version) => [
|
|
102
|
+
'NAME="Buninu Linux"',
|
|
103
|
+
"ID=buninu-linux",
|
|
104
|
+
`VERSION="${version}"`,
|
|
105
|
+
`VERSION_ID=${version}`,
|
|
106
|
+
`PRETTY_NAME="Buninu Linux ${version}"`,
|
|
107
|
+
'HOME_URL="https://buninu.org"',
|
|
108
|
+
'BUG_REPORT_URL="https://github.com/jjtseng93/buninu-linux/issues"',
|
|
109
|
+
"LOGO=buninu-linux",
|
|
110
|
+
"DEFAULT_HOSTNAME=buninu",
|
|
111
|
+
].map((line) => `${line}\n`).join("");
|
|
112
|
+
|
|
113
|
+
export const pack = (cfg = config()) => {
|
|
114
|
+
// The directories and files made here are packed with their modes; native
|
|
115
|
+
// Termux's umask 0077 would make /usr/lib and /usr/lib/os-release private.
|
|
116
|
+
const previousUmask = process.umask(0o022);
|
|
117
|
+
try {
|
|
118
|
+
packStaging(cfg);
|
|
119
|
+
} finally {
|
|
120
|
+
process.umask(previousUmask);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const packStaging = (cfg) => {
|
|
125
|
+
const bun = join(cfg.nativeDir, "bin", "bun");
|
|
126
|
+
if (!existsSync(bun) || !(statSync(bun).mode & 0o111)) {
|
|
127
|
+
throw new Error(`no ${bun} yet; run the fetch stage first (node scripts/fetch.js)`);
|
|
128
|
+
}
|
|
129
|
+
const build = join(rootDir, "build");
|
|
130
|
+
mkdirSync(build, { recursive: true });
|
|
131
|
+
const staging = mkdtempSync(join(rootDir, ".initramfs."));
|
|
132
|
+
try {
|
|
133
|
+
// initramfs/ is the same for every architecture; native/<arch>/ adds bun,
|
|
134
|
+
// the musl loader, the GCC runtime and the kernel modules on top of it.
|
|
135
|
+
// A checkout fetched before native/ existed still has an x86_64 bun and
|
|
136
|
+
// its modules under initramfs/; they are dropped rather than packed.
|
|
137
|
+
copyTree(join(rootDir, "initramfs"), staging);
|
|
138
|
+
rmSync(join(staging, "bin", "bun"), { force: true });
|
|
139
|
+
rmSync(join(staging, "lib", "modules"), { recursive: true, force: true });
|
|
140
|
+
copyTree(cfg.nativeDir, staging);
|
|
141
|
+
// Only this kernel's modules: native/<arch>/ keeps every release fetched
|
|
142
|
+
// so far (an older pin, the other flavor), and none of those can load.
|
|
143
|
+
const modules = join(staging, "lib", "modules");
|
|
144
|
+
for (const release of existsSync(modules) ? readdirSync(modules) : []) {
|
|
145
|
+
if (release !== cfg.kernelRelease) rmSync(join(modules, release), { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
copyFile(join(rootDir, "README.md"), join(staging, "usr/share/doc/buninu-linux/README.md"));
|
|
148
|
+
for (const name of [
|
|
149
|
+
"LICENSE",
|
|
150
|
+
"NOTICE.md",
|
|
151
|
+
"LICENSES",
|
|
152
|
+
]) copyTree(join(rootDir, name), join(staging, "usr/share/licenses", name));
|
|
153
|
+
|
|
154
|
+
// os-release(5): the canonical file in /usr/lib, /etc/os-release a link
|
|
155
|
+
// to it; build.js embeds the same file as the UKI's .osrel section.
|
|
156
|
+
const { version } = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8"));
|
|
157
|
+
if (!version) throw new Error("no version in package.json");
|
|
158
|
+
writeFileSync(join(build, "os-release"), osRelease(version));
|
|
159
|
+
mkdirSync(join(staging, "usr/lib"), { recursive: true });
|
|
160
|
+
mkdirSync(join(staging, "etc"), { recursive: true });
|
|
161
|
+
copyFile(join(build, "os-release"), join(staging, "usr/lib/os-release"));
|
|
162
|
+
link("../usr/lib/os-release", join(staging, "etc/os-release"));
|
|
163
|
+
// LOGO names an icon-theme icon; the standard lookup paths point at the
|
|
164
|
+
// icon /buninu already ships.
|
|
165
|
+
mkdirSync(join(staging, "usr/share/icons/hicolor/512x512/apps"), { recursive: true });
|
|
166
|
+
mkdirSync(join(staging, "usr/share/pixmaps"), { recursive: true });
|
|
167
|
+
link("../../../../../../buninu/icon.png", join(staging, "usr/share/icons/hicolor/512x512/apps/buninu-linux.png"));
|
|
168
|
+
link("../../../buninu/icon.png", join(staging, "usr/share/pixmaps/buninu-linux.png"));
|
|
169
|
+
|
|
170
|
+
normalizeModes(staging);
|
|
171
|
+
const cpio = spawnSync(jsRuntime(), [
|
|
172
|
+
join(rootDir, "scripts/cpio.js"),
|
|
173
|
+
staging,
|
|
174
|
+
join(build, "initramfs.cpio.gz"),
|
|
175
|
+
...devices(cfg),
|
|
176
|
+
], { stdio: ["ignore", "ignore", "inherit"] });
|
|
177
|
+
if (cpio.status !== 0) throw new Error("scripts/cpio.js failed");
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(staging, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
console.log(`Built build/initramfs.cpio.gz for ${cfg.arch}`);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
runIfMain(import.meta.url, () => pack());
|
package/scripts/uki.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Assembles a UKI without binutils: the systemd EFI stub (a PE32+ image,
|
|
3
|
+
// x86_64 or aarch64) with .osrel, .cmdline, .linux and .initrd added as
|
|
4
|
+
// read-only data sections. build-uki.sh runs it instead of objdump/objcopy
|
|
5
|
+
// when BUNINU_JS_UKI=1. Written from the PE/COFF specification against
|
|
6
|
+
// node: APIs only, so it runs under node as well as bun.
|
|
7
|
+
//
|
|
8
|
+
// scripts/uki.js STUB OUTPUT .osrel=FILE .cmdline=FILE .linux=FILE .initrd=FILE
|
|
9
|
+
//
|
|
10
|
+
// The section addresses follow build-uki.sh: the conventional offsets from
|
|
11
|
+
// the image base (.osrel +0x20000, .cmdline +0x30000, .linux +0x2000000,
|
|
12
|
+
// .initrd +0x3000000), moved up when the stub or the kernel is larger. The
|
|
13
|
+
// stub's own headers and sections are kept where they are; the new sections
|
|
14
|
+
// are appended to the file. TimeDateStamp is SOURCE_DATE_EPOCH when set,
|
|
15
|
+
// otherwise the stub's own, so the output depends only on the inputs.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
const fail = (message) => {
|
|
20
|
+
console.error(`uki.js: ${message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const [stubPath, outputPath, ...pairs] = process.argv.slice(2);
|
|
25
|
+
if (!stubPath || !outputPath) fail("usage: uki.js STUB OUTPUT .osrel=FILE .cmdline=FILE .linux=FILE .initrd=FILE");
|
|
26
|
+
const files = new Map();
|
|
27
|
+
for (const pair of pairs) {
|
|
28
|
+
const separator = pair.indexOf("=");
|
|
29
|
+
if (separator < 1) fail(`expected .section=FILE, got ${pair}`);
|
|
30
|
+
files.set(pair.slice(0, separator), pair.slice(separator + 1));
|
|
31
|
+
}
|
|
32
|
+
const order = [".osrel", ".cmdline", ".linux", ".initrd"];
|
|
33
|
+
for (const name of order) if (!files.has(name)) fail(`missing ${name}=FILE`);
|
|
34
|
+
if (files.size !== order.length) fail(`only ${order.join(", ")} are supported`);
|
|
35
|
+
|
|
36
|
+
const stub = readFileSync(stubPath);
|
|
37
|
+
if (stub.toString("latin1", 0, 2) !== "MZ") fail(`${stubPath} is not a PE image`);
|
|
38
|
+
const pe = stub.readUInt32LE(0x3c);
|
|
39
|
+
if (stub.toString("latin1", pe, pe + 4) !== "PE\0\0") fail(`${stubPath} is not a PE image`);
|
|
40
|
+
const coff = pe + 4;
|
|
41
|
+
const sectionCount = stub.readUInt16LE(coff + 2);
|
|
42
|
+
const optionalSize = stub.readUInt16LE(coff + 16);
|
|
43
|
+
const optional = coff + 20;
|
|
44
|
+
if (stub.readUInt16LE(optional) !== 0x20b) fail(`${stubPath} is not PE32+`);
|
|
45
|
+
if (stub.readUInt32LE(coff + 8) !== 0) fail(`${stubPath} has a COFF symbol table, which is not supported`);
|
|
46
|
+
const imageBase = stub.readBigUInt64LE(optional + 24);
|
|
47
|
+
const sectionAlignment = stub.readUInt32LE(optional + 32);
|
|
48
|
+
const fileAlignment = stub.readUInt32LE(optional + 36);
|
|
49
|
+
const stubImageSize = stub.readUInt32LE(optional + 56);
|
|
50
|
+
const headersSize = stub.readUInt32LE(optional + 60);
|
|
51
|
+
const directoryCount = stub.readUInt32LE(optional + 108);
|
|
52
|
+
// A signature (the certificate table) covers the file up to its end;
|
|
53
|
+
// appending after it would invalidate it.
|
|
54
|
+
if (directoryCount > 4 && stub.readUInt32LE(optional + 112 + 4 * 8 + 4) !== 0) {
|
|
55
|
+
fail(`${stubPath} is signed; adding sections would break the signature`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const table = optional + optionalSize;
|
|
59
|
+
let firstData = stub.length;
|
|
60
|
+
for (let index = 0; index < sectionCount; index++) {
|
|
61
|
+
const header = table + index * 40;
|
|
62
|
+
const raw = stub.readUInt32LE(header + 20);
|
|
63
|
+
if (stub.readUInt32LE(header + 16) && raw) firstData = Math.min(firstData, raw);
|
|
64
|
+
}
|
|
65
|
+
const tableEnd = table + (sectionCount + order.length) * 40;
|
|
66
|
+
if (tableEnd > Math.min(headersSize, firstData)) {
|
|
67
|
+
fail(`no room in ${stubPath}'s headers for ${order.length} more section headers`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const alignUp = (value, alignment) => Math.ceil(value / alignment) * alignment;
|
|
71
|
+
const data = order.map((name) => readFileSync(files.get(name)));
|
|
72
|
+
const [, , kernel] = data;
|
|
73
|
+
const osrel = Math.max(0x20000, alignUp(stubImageSize, 0x10000));
|
|
74
|
+
const cmdline = osrel + 0x10000;
|
|
75
|
+
const linux = Math.max(0x2000000, alignUp(cmdline + 0x10000, 0x1000000));
|
|
76
|
+
const initrd = Math.max(0x3000000, alignUp(linux + kernel.length, 0x1000000));
|
|
77
|
+
const addresses = [osrel, cmdline, linux, initrd];
|
|
78
|
+
if (data[0].length > 0x10000 || data[1].length > 0x10000) fail(".osrel and .cmdline must each fit in 64 KiB");
|
|
79
|
+
|
|
80
|
+
// Section contents go after everything already in the file, each at a
|
|
81
|
+
// FileAlignment boundary and padded to a multiple of it.
|
|
82
|
+
let offset = alignUp(stub.length, fileAlignment);
|
|
83
|
+
const pointers = data.map((bytes) => {
|
|
84
|
+
const pointer = offset;
|
|
85
|
+
offset += alignUp(bytes.length, fileAlignment);
|
|
86
|
+
return pointer;
|
|
87
|
+
});
|
|
88
|
+
const output = Buffer.alloc(offset);
|
|
89
|
+
stub.copy(output);
|
|
90
|
+
order.forEach((name, index) => {
|
|
91
|
+
const header = table + (sectionCount + index) * 40;
|
|
92
|
+
const bytes = data[index];
|
|
93
|
+
output.fill(0, header, header + 40);
|
|
94
|
+
output.write(name, header, 8, "latin1");
|
|
95
|
+
output.writeUInt32LE(bytes.length, header + 8); // VirtualSize
|
|
96
|
+
output.writeUInt32LE(addresses[index], header + 12); // VirtualAddress
|
|
97
|
+
output.writeUInt32LE(alignUp(bytes.length, fileAlignment), header + 16); // SizeOfRawData
|
|
98
|
+
output.writeUInt32LE(pointers[index], header + 20); // PointerToRawData
|
|
99
|
+
// IMAGE_SCN_CNT_INITIALIZED_DATA | IMAGE_SCN_MEM_READ: objcopy's
|
|
100
|
+
// contents,alloc,load,readonly,data.
|
|
101
|
+
output.writeUInt32LE(0x40000040, header + 36);
|
|
102
|
+
bytes.copy(output, pointers[index]);
|
|
103
|
+
});
|
|
104
|
+
output.writeUInt16LE(sectionCount + order.length, coff + 2);
|
|
105
|
+
// SizeOfCode, SizeOfInitializedData and SizeOfUninitializedData: the raw
|
|
106
|
+
// sizes of the sections of each kind, recomputed as objcopy does.
|
|
107
|
+
const sizes = [0, 0, 0];
|
|
108
|
+
for (let index = 0; index < sectionCount + order.length; index++) {
|
|
109
|
+
const header = table + index * 40;
|
|
110
|
+
const characteristics = output.readUInt32LE(header + 36);
|
|
111
|
+
const rawSize = output.readUInt32LE(header + 16);
|
|
112
|
+
[0x20, 0x40, 0x80].forEach((flag, kind) => { if (characteristics & flag) sizes[kind] += rawSize; });
|
|
113
|
+
}
|
|
114
|
+
sizes.forEach((size, kind) => output.writeUInt32LE(size >>> 0, optional + 4 + kind * 4));
|
|
115
|
+
output.writeUInt32LE(alignUp(initrd + data[3].length, sectionAlignment), optional + 56); // SizeOfImage
|
|
116
|
+
if (process.env.SOURCE_DATE_EPOCH !== undefined) {
|
|
117
|
+
output.writeUInt32LE(Number(process.env.SOURCE_DATE_EPOCH) >>> 0, coff + 4);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The PE checksum: 16-bit one's-complement-style sum of the file with the
|
|
121
|
+
// CheckSum field itself taken as zero, plus the file length.
|
|
122
|
+
output.writeUInt32LE(0, optional + 64);
|
|
123
|
+
let sum = 0;
|
|
124
|
+
for (let index = 0; index + 1 < output.length; index += 2) {
|
|
125
|
+
sum += output.readUInt16LE(index);
|
|
126
|
+
sum = (sum & 0xffff) + (sum >>> 16);
|
|
127
|
+
}
|
|
128
|
+
if (output.length & 1) {
|
|
129
|
+
sum += output[output.length - 1];
|
|
130
|
+
sum = (sum & 0xffff) + (sum >>> 16);
|
|
131
|
+
}
|
|
132
|
+
output.writeUInt32LE(((sum & 0xffff) + output.length) >>> 0, optional + 64);
|
|
133
|
+
|
|
134
|
+
writeFileSync(outputPath, output);
|
|
135
|
+
const hex = (value) => `0x${value.toString(16)}`;
|
|
136
|
+
console.log(`uki.js: ${outputPath}: image base ${hex(imageBase)}, ${order.map((name, index) => `${name} +${hex(addresses[index])}`).join(", ")}`);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The JS tools in initramfs/bin against the tools they stand in for, called
|
|
2
|
+
// with the flags the build scripts use. Outputs must be byte for byte the
|
|
3
|
+
// same, except where noted: mkfs.fat's boot code (GPL; ours only halts),
|
|
4
|
+
// the random GUIDs of parted, and gzip's compressed bytes (zlib is a
|
|
5
|
+
// different deflate encoder, so only its decompressed output is compared).
|
|
6
|
+
// A test is skipped when the reference tool is not installed.
|
|
7
|
+
//
|
|
8
|
+
// bun test test/js-tools.test.js
|
|
9
|
+
|
|
10
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { readGpt, writeGpt } from "../initramfs/lib/gpt.js";
|
|
16
|
+
|
|
17
|
+
const bin = join(import.meta.dir, "../initramfs/bin");
|
|
18
|
+
const work = mkdtempSync(join(tmpdir(), "buninu-js-tools-"));
|
|
19
|
+
afterAll(() => rmSync(work, { recursive: true, force: true }));
|
|
20
|
+
const path = (name) => join(work, name);
|
|
21
|
+
|
|
22
|
+
// Same times everywhere, so mkfs.fat's volume ID and every FAT timestamp agree.
|
|
23
|
+
const env = { ...process.env, SOURCE_DATE_EPOCH: "1700000000", LC_ALL: "C" };
|
|
24
|
+
const have = (tool) => spawnSync("sh", ["-c", `command -v ${tool}`], { env }).status === 0;
|
|
25
|
+
|
|
26
|
+
const run = (command, args, options = {}) => {
|
|
27
|
+
const result = spawnSync(command, args, { env, cwd: work, maxBuffer: 1 << 30, ...options });
|
|
28
|
+
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited ${result.status}: ${result.stderr}`);
|
|
29
|
+
return result.stdout;
|
|
30
|
+
};
|
|
31
|
+
const gnu = (tool, args, options) => run(tool, args, options);
|
|
32
|
+
const js = (tool, args, options) => run(process.execPath, [join(bin, tool), ...args], options);
|
|
33
|
+
const pick = (which) => (which === "gnu" ? gnu : js);
|
|
34
|
+
|
|
35
|
+
/** Deterministic pseudo-random bytes (compressible enough to be realistic). */
|
|
36
|
+
const bytes = (length, seed = 1) => {
|
|
37
|
+
const buffer = Buffer.alloc(length);
|
|
38
|
+
let state = seed;
|
|
39
|
+
for (let index = 0; index < length; index++) {
|
|
40
|
+
state = (state * 1103515245 + 12345) >>> 0;
|
|
41
|
+
buffer[index] = index % 7 === 0 ? 0 : state >>> 24;
|
|
42
|
+
}
|
|
43
|
+
return buffer;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** mkfs.fat's boot code (bytes 90..509 of the boot sector and its backup) is not compared. */
|
|
47
|
+
const maskBootCode = (image, offset = 0) => {
|
|
48
|
+
for (const sector of [0, 6]) image.fill(0, offset + sector * 512 + 90, offset + sector * 512 + 510);
|
|
49
|
+
return image;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const expectSameBytes = (a, b) => {
|
|
53
|
+
expect(a.length).toBe(b.length);
|
|
54
|
+
const first = a.findIndex((byte, index) => byte !== b[index]);
|
|
55
|
+
expect(first).toBe(-1);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("gzip", () => {
|
|
59
|
+
const payload = bytes(200_000);
|
|
60
|
+
test.skipIf(!have("gzip"))("gzip -dc decompresses GNU gzip -9n output, as fetch-alpine.sh does with .ko.gz", () => {
|
|
61
|
+
writeFileSync(path("payload"), payload);
|
|
62
|
+
const compressed = gnu("gzip", ["-9n", "-c", path("payload")]);
|
|
63
|
+
expectSameBytes(js("gzip", ["-dc"], { input: compressed }), gnu("gzip", ["-dc"], { input: compressed }));
|
|
64
|
+
});
|
|
65
|
+
test.skipIf(!have("gzip"))("concatenated members (an .apk) decompress as one stream", () => {
|
|
66
|
+
const members = Buffer.concat([gnu("gzip", ["-9n"], { input: payload.subarray(0, 1000) }), gnu("gzip", ["-9n"], { input: payload.subarray(1000) })]);
|
|
67
|
+
expectSameBytes(js("gzip", ["-dc"], { input: members }), gnu("gzip", ["-dc"], { input: members }));
|
|
68
|
+
});
|
|
69
|
+
test.skipIf(!have("gzip"))("gunzip -c and zcat match gzip -dc", () => {
|
|
70
|
+
const compressed = gnu("gzip", ["-9n"], { input: payload });
|
|
71
|
+
writeFileSync(path("payload.gz"), compressed);
|
|
72
|
+
expectSameBytes(js("gunzip", ["-c", path("payload.gz")]), payload);
|
|
73
|
+
expectSameBytes(js("zcat", [path("payload.gz")]), payload);
|
|
74
|
+
});
|
|
75
|
+
test.skipIf(!have("gzip"))("gzip -9n output is a valid gzip stream for GNU gzip (bytes differ: another encoder)", () => {
|
|
76
|
+
expectSameBytes(gnu("gzip", ["-dc"], { input: js("gzip", ["-9n"], { input: payload }) }), payload);
|
|
77
|
+
});
|
|
78
|
+
test("in place: FILE -> FILE.gz -> FILE", () => {
|
|
79
|
+
writeFileSync(path("inplace"), payload);
|
|
80
|
+
js("gzip", [path("inplace")]);
|
|
81
|
+
expect(() => statSync(path("inplace"))).toThrow();
|
|
82
|
+
js("gunzip", [path("inplace.gz")]);
|
|
83
|
+
expectSameBytes(readFileSync(path("inplace")), payload);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("unzip", () => {
|
|
88
|
+
test.skipIf(!have("zip") || !have("unzip"))("unzip -p MEMBER matches Info-ZIP, as fetch-bun.sh extracts bun", () => {
|
|
89
|
+
writeFileSync(path("bun"), bytes(3_000_000, 7));
|
|
90
|
+
writeFileSync(path("notes.txt"), "stored\n");
|
|
91
|
+
run("sh", ["-c", "mkdir -p zipped/dir && cp bun zipped/dir/bun && cp notes.txt zipped/ && zip -qr -X archive.zip zipped && zip -q -0 -X archive.zip notes.txt"]);
|
|
92
|
+
for (const member of ["zipped/dir/bun", "notes.txt", "zipped/notes.txt"]) {
|
|
93
|
+
expectSameBytes(js("unzip", ["-p", path("archive.zip"), member]), gnu("unzip", ["-p", path("archive.zip"), member]));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
test.skipIf(!have("zip") || !have("unzip"))("unzip -d extracts the same tree as Info-ZIP", () => {
|
|
97
|
+
js("unzip", ["-q", "-o", path("archive.zip"), "-d", path("js-tree")]);
|
|
98
|
+
gnu("unzip", ["-q", "-o", path("archive.zip"), "-d", path("gnu-tree")]);
|
|
99
|
+
expectSameBytes(readFileSync(path("js-tree/zipped/dir/bun")), readFileSync(path("gnu-tree/zipped/dir/bun")));
|
|
100
|
+
expect(statSync(path("js-tree/zipped/dir/bun")).mode).toBe(statSync(path("gnu-tree/zipped/dir/bun")).mode);
|
|
101
|
+
});
|
|
102
|
+
const official = join(import.meta.dir, "../downloads/x86_64/bun-linux-x64-musl-baseline-1.4.2.zip");
|
|
103
|
+
test.skipIf(!have("unzip") || !(() => { try { return statSync(official).isFile(); } catch { return false; } })())(
|
|
104
|
+
"the pinned Bun release zip, as fetch-bun.sh reads it", () => {
|
|
105
|
+
const member = "bun-linux-x64-musl-baseline/bun";
|
|
106
|
+
expectSameBytes(js("unzip", ["-p", official, member]), gnu("unzip", ["-p", official, member]));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("truncate and dd", () => {
|
|
111
|
+
test.skipIf(!have("truncate"))("truncate -s SIZE creates the same sparse file as coreutils", () => {
|
|
112
|
+
const size = String(260063 * 512);
|
|
113
|
+
js("truncate", ["-s", size, path("js-t")]);
|
|
114
|
+
gnu("truncate", ["-s", size, path("gnu-t")]);
|
|
115
|
+
expect(statSync(path("js-t")).size).toBe(statSync(path("gnu-t")).size);
|
|
116
|
+
js("truncate", ["-s", "+1M", path("js-t")]);
|
|
117
|
+
gnu("truncate", ["-s", "+1M", path("gnu-t")]);
|
|
118
|
+
expectSameBytes(readFileSync(path("js-t")), readFileSync(path("gnu-t")));
|
|
119
|
+
});
|
|
120
|
+
test.skipIf(!have("dd"))("dd bs=512 seek= count= conv=notrunc,sparse status=none, as build-image.sh places the ESP", () => {
|
|
121
|
+
const source = Buffer.concat([bytes(4096, 3), Buffer.alloc(8192), bytes(1000, 4)]);
|
|
122
|
+
writeFileSync(path("esp-part"), source);
|
|
123
|
+
for (const which of ["gnu", "js"]) {
|
|
124
|
+
writeFileSync(path(`${which}-disk`), bytes(64 * 512, 9));
|
|
125
|
+
pick(which)("dd", [`if=${path("esp-part")}`, `of=${path(`${which}-disk`)}`, "bs=512", "seek=8", `count=${Math.ceil(source.length / 512)}`, "conv=notrunc,sparse", "status=none"]);
|
|
126
|
+
}
|
|
127
|
+
expectSameBytes(readFileSync(path("js-disk")), readFileSync(path("gnu-disk")));
|
|
128
|
+
});
|
|
129
|
+
test.skipIf(!have("dd"))("dd without notrunc cuts the output at the seek point", () => {
|
|
130
|
+
for (const which of ["gnu", "js"]) {
|
|
131
|
+
writeFileSync(path(`${which}-cut`), bytes(10_000, 5));
|
|
132
|
+
pick(which)("dd", [`if=${path("esp-part")}`, `of=${path(`${which}-cut`)}`, "bs=512", "seek=2", "count=3", "status=none"]);
|
|
133
|
+
}
|
|
134
|
+
expectSameBytes(readFileSync(path("js-cut")), readFileSync(path("gnu-cut")));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("FAT32: mkfs.fat, mmd, mcopy", () => {
|
|
139
|
+
const fatTools = have("mkfs.fat") && have("mmd") && have("mcopy");
|
|
140
|
+
const uki = bytes(5_000_000, 11);
|
|
141
|
+
|
|
142
|
+
test.skipIf(!fatTools)("the ESP of build-image.sh: mkfs.fat -F 32 -h 2048 -n EFIBOOT, mmd, mcopy", () => {
|
|
143
|
+
writeFileSync(path("uki.efi"), uki);
|
|
144
|
+
for (const which of ["gnu", "js"]) {
|
|
145
|
+
const tool = pick(which);
|
|
146
|
+
const image = path(`${which}-esp.img`);
|
|
147
|
+
tool("truncate", ["-s", String(260063 * 512), image]);
|
|
148
|
+
tool("mkfs.fat", ["-F", "32", "-h", "2048", "-n", "EFIBOOT", image]);
|
|
149
|
+
tool("mmd", ["-i", image, "::/EFI", "::/EFI/BOOT"]);
|
|
150
|
+
tool("mcopy", ["-i", image, path("uki.efi"), "::/EFI/BOOT/BOOTX64.EFI"]);
|
|
151
|
+
}
|
|
152
|
+
expectSameBytes(maskBootCode(readFileSync(path("js-esp.img"))), maskBootCode(readFileSync(path("gnu-esp.img"))));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test.skipIf(!fatTools)("--invariant, lower-case and long names, and a directory that outgrows its cluster", () => {
|
|
156
|
+
writeFileSync(path("small.bin"), bytes(5000, 13));
|
|
157
|
+
for (const which of ["gnu", "js"]) {
|
|
158
|
+
const tool = pick(which);
|
|
159
|
+
const image = path(`${which}-edge.img`);
|
|
160
|
+
tool("truncate", ["-s", String(100000 * 512), image]);
|
|
161
|
+
tool("mkfs.fat", ["--invariant", "-F", "32", "-n", "MYLABEL", image]);
|
|
162
|
+
tool("mmd", ["-i", image, "::/EFI", "::/EFI/BOOT", "::/Mixed Case Dir"]);
|
|
163
|
+
tool("mcopy", ["-i", image, path("small.bin"), "::/EFI/BOOT/lower.efi"]);
|
|
164
|
+
tool("mcopy", ["-i", image, path("small.bin"), "::/Mixed Case Dir/Long File Name.txt"]);
|
|
165
|
+
tool("mcopy", ["-i", image, path("small.bin"), "::/README"]);
|
|
166
|
+
for (let index = 1; index <= 40; index++) tool("mcopy", ["-i", image, path("small.bin"), `::/EFI/file${index}.dat`]);
|
|
167
|
+
}
|
|
168
|
+
expectSameBytes(maskBootCode(readFileSync(path("js-edge.img"))), maskBootCode(readFileSync(path("gnu-edge.img"))));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test.skipIf(!fatTools)("each side reads the other's image (mcopy -n -i IMAGE ::/file out)", () => {
|
|
172
|
+
gnu("mcopy", ["-n", "-i", path("js-esp.img"), "::/EFI/BOOT/BOOTX64.EFI", path("from-js.efi")]);
|
|
173
|
+
js("mcopy", ["-n", "-i", path("gnu-esp.img"), "::/EFI/BOOT/BOOTX64.EFI", path("from-gnu.efi")]);
|
|
174
|
+
expectSameBytes(readFileSync(path("from-js.efi")), uki);
|
|
175
|
+
expectSameBytes(readFileSync(path("from-gnu.efi")), uki);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test.skipIf(!have("fsck.fat") || !fatTools)("fsck.fat -n finds nothing wrong in the JS image", () => {
|
|
179
|
+
expect(gnu("fsck.fat", ["-n", path("js-edge.img")]).toString()).toContain("files,");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("GPT: parted", () => {
|
|
184
|
+
test.skipIf(!have("parted"))("parted -s IMAGE mklabel gpt / unit s mkpart ESP fat32 / set 1 esp on, as build-image.sh", () => {
|
|
185
|
+
const sectors = 262144;
|
|
186
|
+
for (const which of ["gnu", "js"]) {
|
|
187
|
+
const tool = pick(which);
|
|
188
|
+
const image = path(`${which}-gpt.img`);
|
|
189
|
+
tool("truncate", ["-s", String(sectors * 512), image]);
|
|
190
|
+
tool("parted", ["-s", image, "mklabel", "gpt"]);
|
|
191
|
+
tool("parted", ["-s", image, "unit", "s", "mkpart", "ESP", "fat32", "2048s", `${sectors - 34}s`]);
|
|
192
|
+
tool("parted", ["-s", image, "set", "1", "esp", "on"]);
|
|
193
|
+
}
|
|
194
|
+
// The GUIDs are random on both sides: give ours parted's, then compare everything.
|
|
195
|
+
const reference = openSync(path("gnu-gpt.img"), "r");
|
|
196
|
+
const gnuTable = readGpt(reference, sectors);
|
|
197
|
+
closeSync(reference);
|
|
198
|
+
const ours = openSync(path("js-gpt.img"), "r+");
|
|
199
|
+
const jsTable = readGpt(ours, sectors);
|
|
200
|
+
expect(jsTable.entries.map(({ guid, ...rest }) => rest)).toEqual(gnuTable.entries.map(({ guid, ...rest }) => rest));
|
|
201
|
+
jsTable.diskGuid = gnuTable.diskGuid;
|
|
202
|
+
jsTable.entries.forEach((entry, index) => { entry.guid = gnuTable.entries[index].guid; });
|
|
203
|
+
writeGpt(ours, sectors, jsTable);
|
|
204
|
+
closeSync(ours);
|
|
205
|
+
expectSameBytes(readFileSync(path("js-gpt.img")), readFileSync(path("gnu-gpt.img")));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test.skipIf(!have("sfdisk") || !have("parted"))("sfdisk reads the JS table", () => {
|
|
209
|
+
const dump = gnu("sfdisk", ["-d", path("js-gpt.img")]).toString();
|
|
210
|
+
expect(dump).toContain("start= 2048, size= 260063, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B");
|
|
211
|
+
expect(dump).toContain('name="ESP"');
|
|
212
|
+
});
|
|
213
|
+
});
|