build: binary patching with Zig

This commit is contained in:
Mitchell Hashimoto
2026-03-30 11:46:07 -07:00
parent 6c085e5442
commit 01a8ea7212
2 changed files with 291 additions and 21 deletions

View File

@@ -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,

View 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)));
}