From 01a8ea72125c4de72101637805a4c920eb54d34b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Mar 2026 11:46:07 -0700 Subject: [PATCH] build: binary patching with Zig --- src/build/GhosttyLibVt.zig | 43 ++-- src/build/wasm_patch_growable_table.zig | 269 ++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 src/build/wasm_patch_growable_table.zig diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index db7f2e2a5..10d988b38 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -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, diff --git a/src/build/wasm_patch_growable_table.zig b/src/build/wasm_patch_growable_table.zig new file mode 100644 index 000000000..c40c0a9c8 --- /dev/null +++ b/src/build/wasm_patch_growable_table.zig @@ -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 + +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 ", .{}); + 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))); +} -- 2.51.2