mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
build: binary patching with Zig
This commit is contained in:
@@ -52,29 +52,30 @@ pub fn initWasm(
|
||||
exe.entry = .disabled;
|
||||
|
||||
// Zig's WASM linker doesn't support --growable-table, so the table
|
||||
// has a fixed max equal to its initial size. Post-process with wabt
|
||||
// tools (wasm2wat → sed → wat2wasm) to remove the max limit, making
|
||||
// the table growable from JS via Table.grow().
|
||||
const wasm2wat = b.addSystemCommand(&.{"wasm2wat"});
|
||||
wasm2wat.addFileArg(exe.getEmittedBin());
|
||||
|
||||
const awk = b.addSystemCommand(&.{
|
||||
"awk",
|
||||
// Remove the table max from "(table (;0;) MIN MAX funcref)"
|
||||
// so that it becomes "(table (;0;) MIN funcref)", making the
|
||||
// table growable from JS.
|
||||
"/\\(table \\(;[0-9]+;\\) [0-9]+ [0-9]+ funcref\\)/ { sub(/ [0-9]+ funcref\\)/, \" funcref)\") } 1",
|
||||
});
|
||||
awk.addFileArg(wasm2wat.captureStdOut());
|
||||
|
||||
const wat2wasm = b.addSystemCommand(&.{ "wat2wasm", "--enable-all" });
|
||||
wat2wasm.addFileArg(awk.captureStdOut());
|
||||
wat2wasm.addArgs(&.{"-o"});
|
||||
const output = wat2wasm.addOutputFileArg("ghostty-vt.wasm");
|
||||
// is emitted with max == min and can't be grown from JS. Run a
|
||||
// small Zig build tool that patches the binary's table section to
|
||||
// remove the max limit.
|
||||
const patch_run = patch: {
|
||||
const patcher = b.addExecutable(.{
|
||||
.name = "wasm_patch_growable_table",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/build/wasm_patch_growable_table.zig"),
|
||||
.target = b.graph.host,
|
||||
}),
|
||||
});
|
||||
break :patch b.addRunArtifact(patcher);
|
||||
};
|
||||
patch_run.addFileArg(exe.getEmittedBin());
|
||||
const output = patch_run.addOutputFileArg("ghostty-vt.wasm");
|
||||
const artifact_install = b.addInstallFileWithDir(
|
||||
output,
|
||||
.bin,
|
||||
"ghostty-vt.wasm",
|
||||
);
|
||||
|
||||
return .{
|
||||
.step = &wat2wasm.step,
|
||||
.artifact = &b.addInstallFileWithDir(output, .bin, "ghostty-vt.wasm").step,
|
||||
.step = &patch_run.step,
|
||||
.artifact = &artifact_install.step,
|
||||
.kind = .wasm,
|
||||
.output = output,
|
||||
.dsym = null,
|
||||
|
||||
269
src/build/wasm_patch_growable_table.zig
Normal file
269
src/build/wasm_patch_growable_table.zig
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Build tool that patches a WASM binary to make the function table
|
||||
//! growable by removing its maximum size limit.
|
||||
//!
|
||||
//! Zig's WASM linker doesn't support `--growable-table`, so the table
|
||||
//! is emitted with max == min. This tool finds the table section (id 4)
|
||||
//! and changes the limits flag from 0x01 (has max) to 0x00 (no max),
|
||||
//! removing the max field.
|
||||
//!
|
||||
//! Usage: wasm_growable_table <input.wasm> <output.wasm>
|
||||
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub fn main() !void {
|
||||
// This is a one-off patcher, so we leak all our memory on purpose
|
||||
// and let the OS clean it up when we exit.
|
||||
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
|
||||
const alloc = gpa.allocator();
|
||||
|
||||
// Parse args: program input output
|
||||
const args = try std.process.argsAlloc(alloc);
|
||||
defer std.process.argsFree(alloc, args);
|
||||
if (args.len != 3) {
|
||||
std.log.err("usage: wasm_growable_table <input.wasm> <output.wasm>", .{});
|
||||
std.process.exit(1);
|
||||
unreachable;
|
||||
}
|
||||
|
||||
// Patch the file.
|
||||
const output: []const u8 = try patchTableGrowable(
|
||||
alloc,
|
||||
try std.fs.cwd().readFileAlloc(
|
||||
alloc,
|
||||
args[1],
|
||||
std.math.maxInt(usize),
|
||||
),
|
||||
);
|
||||
|
||||
// Write our output
|
||||
const out_file = try std.fs.cwd().createFile(args[2], .{});
|
||||
defer out_file.close();
|
||||
try out_file.writeAll(output);
|
||||
}
|
||||
|
||||
/// Patch the WASM binary's table section to remove the maximum size
|
||||
/// limit, making the table growable. If the table already has no max
|
||||
/// or no table section is found, the input is returned unchanged.
|
||||
///
|
||||
/// The WASM table section (id=4) encodes table limits as:
|
||||
/// - flags=0x00, min (LEB128) — no max, growable
|
||||
/// - flags=0x01, min (LEB128), max (LEB128) — bounded, not growable
|
||||
///
|
||||
/// This function rewrites the section to use flags=0x00, dropping the
|
||||
/// max field entirely.
|
||||
fn patchTableGrowable(
|
||||
alloc: Allocator,
|
||||
input: []const u8,
|
||||
) (error{InvalidWasm} || std.Io.Writer.Error)![]const u8 {
|
||||
if (input.len < 8) return error.InvalidWasm;
|
||||
|
||||
// Start after the 8-byte WASM header (magic + version).
|
||||
var pos: usize = 8;
|
||||
|
||||
while (pos < input.len) {
|
||||
const section_id = input[pos];
|
||||
pos += 1;
|
||||
const section_size = readLeb128(input, &pos);
|
||||
const section_start = pos;
|
||||
|
||||
// We're looking for section 4 (the table section).
|
||||
if (section_id != 4) {
|
||||
pos = section_start + section_size;
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = readLeb128(input, &pos); // table count
|
||||
pos += 1; // elem_type (0x70 = funcref)
|
||||
const flags = input[pos];
|
||||
|
||||
// flags bit 0 indicates whether a max is present.
|
||||
if (flags & 1 == 0) {
|
||||
// Already no max, nothing to patch.
|
||||
return input;
|
||||
}
|
||||
|
||||
// Record positions of each field so we can reconstruct
|
||||
// the section without the max value.
|
||||
const flags_pos = pos;
|
||||
pos += 1; // skip flags byte
|
||||
const min_start = pos;
|
||||
_ = readLeb128(input, &pos); // min
|
||||
const max_start = pos;
|
||||
_ = readLeb128(input, &pos); // max
|
||||
const max_end = pos;
|
||||
const section_end = section_start + section_size;
|
||||
|
||||
// Build the new section payload with the max removed:
|
||||
// [table count + elem_type] [flags=0x00] [min] [trailing data]
|
||||
var payload: std.Io.Writer.Allocating = .init(alloc);
|
||||
try payload.writer.writeAll(input[section_start..flags_pos]);
|
||||
try payload.writer.writeByte(0x00); // flags: no max
|
||||
try payload.writer.writeAll(input[min_start..max_start]);
|
||||
try payload.writer.writeAll(input[max_end..section_end]);
|
||||
|
||||
// Reassemble the full binary:
|
||||
// [everything before this section] [section id] [new size] [new payload] [everything after]
|
||||
const before_section = input[0 .. section_start - 1 - uleb128Size(section_size)];
|
||||
var result: std.Io.Writer.Allocating = .init(alloc);
|
||||
try result.writer.writeAll(before_section);
|
||||
try result.writer.writeByte(4); // table section id
|
||||
try result.writer.writeUleb128(@as(u32, @intCast(payload.written().len)));
|
||||
try result.writer.writeAll(payload.written());
|
||||
try result.writer.writeAll(input[section_end..]);
|
||||
return result.written();
|
||||
}
|
||||
|
||||
// No table section found; return input unchanged.
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Decode an unsigned LEB128 value from `bytes` starting at `pos.*`,
|
||||
/// advancing `pos` past the encoded bytes.
|
||||
fn readLeb128(bytes: []const u8, pos: *usize) u32 {
|
||||
var result: u32 = 0;
|
||||
var shift: u5 = 0;
|
||||
while (true) {
|
||||
const byte = bytes[pos.*];
|
||||
pos.* += 1;
|
||||
result |= @as(u32, byte & 0x7f) << shift;
|
||||
if (byte & 0x80 == 0) return result;
|
||||
shift +%= 7;
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of bytes needed to encode `value` as unsigned LEB128.
|
||||
fn uleb128Size(value: u32) usize {
|
||||
var v = value;
|
||||
var size: usize = 0;
|
||||
while (true) {
|
||||
v >>= 7;
|
||||
size += 1;
|
||||
if (v == 0) return size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal valid WASM module with a bounded table (min=1, max=1).
|
||||
/// Sections: type(1), table(4), export(7).
|
||||
const test_wasm_bounded_table = [_]u8{
|
||||
0x00, 0x61, 0x73, 0x6d, // magic
|
||||
0x01, 0x00, 0x00, 0x00, // version
|
||||
// Type section (id=1): 1 type, () -> ()
|
||||
0x01, 0x04, 0x01, 0x60,
|
||||
0x00, 0x00,
|
||||
// Table section (id=4): 1 table, funcref, flags=1, min=1, max=1
|
||||
0x04, 0x05,
|
||||
0x01, 0x70, 0x01, 0x01,
|
||||
0x01,
|
||||
// Export section (id=7): 0 exports
|
||||
0x07, 0x01, 0x00,
|
||||
};
|
||||
|
||||
/// Same module but the table already has no max (flags=0).
|
||||
const test_wasm_growable_table = [_]u8{
|
||||
0x00, 0x61, 0x73, 0x6d, // magic
|
||||
0x01, 0x00, 0x00, 0x00, // version
|
||||
// Type section (id=1)
|
||||
0x01, 0x04, 0x01, 0x60,
|
||||
0x00, 0x00,
|
||||
// Table section (id=4): 1 table, funcref, flags=0, min=1
|
||||
0x04, 0x04,
|
||||
0x01, 0x70, 0x00, 0x01,
|
||||
// Export section (id=7): 0 exports
|
||||
0x07, 0x01, 0x00,
|
||||
};
|
||||
|
||||
/// Module with no table section at all.
|
||||
const test_wasm_no_table = [_]u8{
|
||||
0x00, 0x61, 0x73, 0x6d, // magic
|
||||
0x01, 0x00, 0x00, 0x00, // version
|
||||
// Type section (id=1)
|
||||
0x01, 0x04, 0x01, 0x60,
|
||||
0x00, 0x00,
|
||||
// Export section (id=7): 0 exports
|
||||
0x07, 0x01,
|
||||
0x00,
|
||||
};
|
||||
|
||||
test "patches bounded table to remove max" {
|
||||
// We use a non-checking allocator because the patched result is
|
||||
// intentionally leaked (matches the real main() usage).
|
||||
const result = try patchTableGrowable(
|
||||
std.heap.page_allocator,
|
||||
&test_wasm_bounded_table,
|
||||
);
|
||||
|
||||
// Result should differ from input (max was removed).
|
||||
try testing.expect(!std.mem.eql(
|
||||
u8,
|
||||
result,
|
||||
&test_wasm_bounded_table,
|
||||
));
|
||||
|
||||
// Find the table section in the output and verify flags=0x00.
|
||||
var pos: usize = 8;
|
||||
while (pos < result.len) {
|
||||
const id = result[pos];
|
||||
pos += 1;
|
||||
const size = readLeb128(result, &pos);
|
||||
if (id == 4) {
|
||||
_ = readLeb128(result, &pos); // table count
|
||||
pos += 1; // elem_type
|
||||
// flags should now be 0x00 (no max).
|
||||
try testing.expectEqual(@as(u8, 0x00), result[pos]);
|
||||
return;
|
||||
}
|
||||
pos += size;
|
||||
}
|
||||
return error.TableSectionNotFound;
|
||||
}
|
||||
|
||||
test "already growable table is returned unchanged" {
|
||||
const result = try patchTableGrowable(
|
||||
testing.allocator,
|
||||
&test_wasm_growable_table,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as([*]const u8, &test_wasm_growable_table),
|
||||
result.ptr,
|
||||
);
|
||||
}
|
||||
|
||||
test "no table section returns input unchanged" {
|
||||
const result = try patchTableGrowable(
|
||||
testing.allocator,
|
||||
&test_wasm_no_table,
|
||||
);
|
||||
try testing.expectEqual(@as([*]const u8, &test_wasm_no_table), result.ptr);
|
||||
}
|
||||
|
||||
test "too short input returns InvalidWasm" {
|
||||
try testing.expectError(
|
||||
error.InvalidWasm,
|
||||
patchTableGrowable(testing.allocator, "short"),
|
||||
);
|
||||
}
|
||||
|
||||
test "readLeb128 single byte" {
|
||||
const bytes = [_]u8{0x05};
|
||||
var pos: usize = 0;
|
||||
try testing.expectEqual(@as(u32, 5), readLeb128(&bytes, &pos));
|
||||
try testing.expectEqual(@as(usize, 1), pos);
|
||||
}
|
||||
|
||||
test "readLeb128 multi byte" {
|
||||
// 300 = 0b100101100 → LEB128: 0xAC 0x02
|
||||
const bytes = [_]u8{ 0xAC, 0x02 };
|
||||
var pos: usize = 0;
|
||||
try testing.expectEqual(@as(u32, 300), readLeb128(&bytes, &pos));
|
||||
try testing.expectEqual(@as(usize, 2), pos);
|
||||
}
|
||||
|
||||
test "uleb128Size" {
|
||||
try testing.expectEqual(@as(usize, 1), uleb128Size(0));
|
||||
try testing.expectEqual(@as(usize, 1), uleb128Size(0x7f));
|
||||
try testing.expectEqual(@as(usize, 2), uleb128Size(0x80));
|
||||
try testing.expectEqual(@as(usize, 2), uleb128Size(300));
|
||||
try testing.expectEqual(@as(usize, 5), uleb128Size(std.math.maxInt(u32)));
|
||||
}
|
||||
Reference in New Issue
Block a user