From 19d1b8b30a606852ee0ea1b60cbea5eb3af016c0 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Wed, 29 Jul 2026 02:26:12 +1000 Subject: [PATCH] G-128: vendor punkres v0.1.0 into src/tools/punkres Upstream commit 09d5ec5514bb0dbd31a38ee772e50b8d981f0219 (clean at copy time) from the sibling checkout c:/repo/jn/zig/punkres (gitea remote recorded in PROVENANCE.md). Vendored: build.zig, build.zig.zon, LICENSE (BSD-2-Clause, first-party), README.md, src/ - all test fixtures synthesized in memory, no binaries. PROVENANCE.md records origin, vendored state, re-vendor procedure and G-063-style licence records; src/tools/AGENTS.md indexes the tree and extends the per-tree verification note (punkres 31/31 as vendored). Verified from the vendored location with the pinned bin/tools/zig-x86_64-windows-0.16.0 toolchain: zig build test 14/14 steps, 31/31 tests. make.tcl tool step discovers punkres with ZERO make.tcl edits (G-126 convention-based discovery): test gate passed, ReleaseSafe build installed bin/punkres.exe (1073152 bytes). Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- src/tools/AGENTS.md | 10 +- src/tools/punkres/LICENSE | 24 + src/tools/punkres/PROVENANCE.md | 71 ++ src/tools/punkres/README.md | 85 +++ src/tools/punkres/build.zig | 63 ++ src/tools/punkres/build.zig.zon | 9 + src/tools/punkres/src/ico.zig | 158 +++++ src/tools/punkres/src/ico_test.zig | 78 +++ src/tools/punkres/src/overlay.zig | 181 ++++++ src/tools/punkres/src/overlay_test.zig | 148 +++++ src/tools/punkres/src/pe.zig | 342 ++++++++++ src/tools/punkres/src/pe_test.zig | 126 ++++ src/tools/punkres/src/punkres.zig | 857 +++++++++++++++++++++++++ src/tools/punkres/src/punkres_test.zig | 476 ++++++++++++++ src/tools/punkres/src/rsrc.zig | 445 +++++++++++++ src/tools/punkres/src/rsrc_test.zig | 156 +++++ src/tools/punkres/src/synthpe.zig | 281 ++++++++ 17 files changed, 3507 insertions(+), 3 deletions(-) create mode 100644 src/tools/punkres/LICENSE create mode 100644 src/tools/punkres/PROVENANCE.md create mode 100644 src/tools/punkres/README.md create mode 100644 src/tools/punkres/build.zig create mode 100644 src/tools/punkres/build.zig.zon create mode 100644 src/tools/punkres/src/ico.zig create mode 100644 src/tools/punkres/src/ico_test.zig create mode 100644 src/tools/punkres/src/overlay.zig create mode 100644 src/tools/punkres/src/overlay_test.zig create mode 100644 src/tools/punkres/src/pe.zig create mode 100644 src/tools/punkres/src/pe_test.zig create mode 100644 src/tools/punkres/src/punkres.zig create mode 100644 src/tools/punkres/src/punkres_test.zig create mode 100644 src/tools/punkres/src/rsrc.zig create mode 100644 src/tools/punkres/src/rsrc_test.zig create mode 100644 src/tools/punkres/src/synthpe.zig diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 2d7c3e75..9ea8c24f 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -3,7 +3,7 @@ ## Purpose First-party build tools vendored as source (zig), built on demand into `bin/` by the -make.tcl tool build step (G-126 establishes the step; the G-128 tool will share it). +make.tcl tool build step (G-126 established the step; punkres shares it per G-128). Distinct from `src/buildsuites/`, which is the arm's-length factory for EXTERNAL Tcl runtime sources fetched per sources.config: the trees here are punkshell-adjacent forks vendored in full, with no fetch step, so a clean checkout carries everything @@ -33,10 +33,14 @@ needed to build them. ## Verification - Per tree: `zig build test` from the tree root with the pinned toolchain must pass - (punkzip: 130/130 as vendored). Gate on the exit code, not clean stderr - the zip - suite's negative tests print EOCD warnings while passing. + (punkzip: 130/130 as vendored; punkres: 31/31 as vendored). Gate on the exit code, + not clean stderr - the zip suite's negative tests print EOCD warnings while + passing, and punkres refusal tests exercise nonzero child exits. ## Child DOX Index - `punkzip/` — vendored punkzip zip read/write tool (G-126 accelerator; no child AGENTS.md - `punkzip/PROVENANCE.md` carries the tree's rules) +- `punkres/` — vendored punkres portable PE resource stamper (G-128; first-party, + BSD-2-Clause; no child AGENTS.md - `punkres/PROVENANCE.md` carries the tree's + rules) diff --git a/src/tools/punkres/LICENSE b/src/tools/punkres/LICENSE new file mode 100644 index 00000000..0a9d8d7c --- /dev/null +++ b/src/tools/punkres/LICENSE @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2026, Julian Noble + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/tools/punkres/PROVENANCE.md b/src/tools/punkres/PROVENANCE.md new file mode 100644 index 00000000..c949fc62 --- /dev/null +++ b/src/tools/punkres/PROVENANCE.md @@ -0,0 +1,71 @@ +# Vendored: punkres + +## Origin + +- Upstream repository: punkres (git; remote + `ssh://git@pcm-gitea1.corp.intx.com.au:2222/jn/punkres`). The working checkout + is expected as a sibling of this repository, at `../zig/punkres` relative to + the punkshell repo root. That is a layout convention, not a requirement - if + no checkout is present there, clone the remote to that location; the Vendored + state commit below identifies the exact upstream state in any clone. +- Vendored state: commit `09d5ec5514bb0dbd31a38ee772e50b8d981f0219` (punkres + v0.1.0, 2026-07-29; upstream working tree clean at copy time) +- Vendored: 2026-07-29 for goal G-128 (see + `goals/G-128-portable-pe-resource-stamping.md`) +- Lineage: first-party punkshell tool, written 2026-07-29 for G-128 - no + external code lineage. It is the portable counterpart to the twapi icon + stamping of G-057: replaces RT_ICON/RT_GROUP_ICON in a FINISHED PE (including + one carrying an appended zip payload) from any build host, preserving the + overlay (archive-relative moved verbatim; file-relative refused by default, + shifted with consent by rewriting the central-directory and EOCD offsets; + zip64/multi-disk refused; non-zip overlay moved only with consent), and reads + its own work back (info/list/extract-ico + built-in set-icon verification). + +## What is vendored + +Everything `zig build` / `zig build test` needs, plus the licence file and +README: `build.zig`, `build.zig.zon`, `LICENSE`, `README.md`, and `src/` +(sources and test suites; all test fixtures are synthesized in memory by +`src/synthpe.zig` - no binary fixtures exist). + +Deliberately NOT vendored: the upstream `.gitignore`. + +## Editing policy + +Do not edit this tree directly. Changes go to the upstream checkout first +(port, stabilise and test there, commit), then re-vendor the committed upstream +state. The fork boundary stays clean and every vendored state corresponds to an +upstream commit (the G-026 provenance direction). `PROVENANCE.md` itself is +punkshell-owned. + +## Re-vendor procedure + +From a CLEAN upstream checkout (commit upstream first; a dirty copy has no +committed provenance). All commands run from the punkshell repo root, with the +upstream checkout at its expected sibling location `../zig/punkres` (see +Origin): + + git -C ../zig/punkres status --short # must be empty + git -C ../zig/punkres rev-parse HEAD # record as the new Vendored state above + + rm -rf src/tools/punkres/src + cp ../zig/punkres/build.zig src/tools/punkres/ + cp ../zig/punkres/build.zig.zon src/tools/punkres/ + cp ../zig/punkres/LICENSE src/tools/punkres/ + cp ../zig/punkres/README.md src/tools/punkres/ + cp -r ../zig/punkres/src src/tools/punkres/src + + # verify with the pinned toolchain (zig build test must pass; gate on the + # exit code): + cd src/tools/punkres + ../../../bin/tools/zig-x86_64-windows-0.16.0/zig.exe build test + + # update the Vendored state line in this file, then commit punkshell. + +## Licensing (G-063-style records) + +- punkres (all files): BSD 2-Clause, Copyright (c) 2026 Julian Noble. + Determination: the vendored `LICENSE` file carries the licence text; the tool + is first-party punkshell-project code with no external lineage; recorded by + agent at vendoring, 2026-07-29. +- No third-party dependencies: `build.zig.zon` declares `.dependencies = .{}`. diff --git a/src/tools/punkres/README.md b/src/tools/punkres/README.md new file mode 100644 index 00000000..ce247ec0 --- /dev/null +++ b/src/tools/punkres/README.md @@ -0,0 +1,85 @@ +# punkres + +Portable PE resource stamper for the punkshell project (goal G-128). + +punkres replaces the RT_ICON / RT_GROUP_ICON resources of a FINISHED Windows +executable - including one carrying an appended zip payload (a punkshell kit) - +from any build host, and reads its own work back so verification needs neither +a Windows host nor twapi. It is the portable counterpart to punkshell's +twapi-based stub stamping (G-057), which remains the no-toolchain path on +Windows. + +## What makes it different from a resource editor + +- The appended payload is a first-class thing to PRESERVE. punkres never grows + `.rsrc` in place (on real tclsh runtimes `.reloc` follows it); it appends a + new `.prsrc` section (or rebuilds a trailing resource section it finds, which + is how a second stamp converges instead of accumulating), repoints the + resource data directory, and relocates the overlay. +- Offset conventions are detected, not assumed: + - archive-relative zip payload: moved verbatim - internal offsets are + relative to the archive start, so nothing needs rewriting. + - FILE-relative zip payload: refused by default. With + `-allow-file-relative-shift` the overlay is moved and every + central-directory local-header offset plus the EOCD directory offset is + rewritten by the shift delta, deliberately preserving the non-standard + file-relative convention. + - zip64 / multi-disk: always refused. + - non-zip overlay bytes: refused by default (their offset semantics are + unknowable); `-allow-opaque-overlay` moves them verbatim. +- Every `set-icon` run re-parses the file it just built and verifies the + resources and the overlay before reporting success (read-back is built in). +- Deterministic: stamping the same input twice yields byte-identical output. + +## Usage + + punkres info [-porcelain] + punkres list [-porcelain] + punkres extract-ico [-group ] + punkres set-icon [-o ] [-allow-file-relative-shift] + [-allow-opaque-overlay] [-strip-signature] + punkres version + +`set-icon` follows the semantics of punkshell's twapi arm exactly: ALL existing +RT_ICON/RT_GROUP_ICON entries are deleted, the .ico images are written as icon +ids 1..N under the first pre-existing group's name and language (name 1 / +lang 1033 when none pre-exist), so the two mechanisms produce equivalent +resource content from the same input. + +Exit codes: 0 ok; 1 error; 2 hard refusal (zip64, multi-disk, certificate +table not at file tail); 3 refusal a consent flag lifts (the message names the +flag). + +stdout is the parseable product (`-porcelain` formats are pinned by the CLI +tests: parsers skip `# `-prefixed and blank lines; first significant line is +`punkres_info_v1` / `punkres_list_v1`); diagnostics go to stderr. + +An Authenticode-signed input is refused unless `-strip-signature` is given +(stamping invalidates the signature regardless); sign AFTER stamping. + +## Building + +Requires zig 0.16.0+ (comptime-gated; `minimum_zig_version` in build.zig.zon). + + zig build # zig-out/bin/punkres[.exe] + zig build test # unit + CLI suites (synthetic fixtures, no binaries) + +There are no dependencies and no committed binary fixtures: tests synthesize +minimal PE images, zips of both offset conventions, and multi-image .ico files +in memory (src/synthpe.zig). + +## Scope notes + +- RT_VERSION stamping is parked but anticipated: the resource tree model is + fully generic (types/names/languages), so it extends this tool rather than + needing another. +- PE32 and PE32+ are both handled; the machine field is reported, not + restricted. +- After an append-mode stamp the ORIGINAL `.rsrc` section remains as dead + weight (its bytes are no longer referenced by the data directory). Tools + that walk section names rather than the data directory may still see the old + data; Windows and anything following the data directory see only the new. + +## License + +BSD 2-Clause. See LICENSE. diff --git a/src/tools/punkres/build.zig b/src/tools/punkres/build.zig new file mode 100644 index 00000000..9e795d68 --- /dev/null +++ b/src/tools/punkres/build.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +fn check_version() void { + const required_zig_version = "0.16.0"; + const current_zig_version = builtin.zig_version_string; + if (std.SemanticVersion.order(try std.SemanticVersion.parse(required_zig_version), try std.SemanticVersion.parse(current_zig_version)) == .gt) { + @compileError("punkres requires zig version: " ++ required_zig_version ++ " to build. Your zig version is: " ++ current_zig_version); + } +} +comptime { + check_version(); +} + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const mode = b.standardOptimizeOption(.{}); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("./src/punkres.zig"), + .target = target, + .optimize = mode, + }); + const exe = b.addExecutable(.{ + .name = "punkres", + .root_module = exe_mod, + }); + + const install_exe = b.addInstallArtifact(exe, .{}); + b.getInstallStep().dependOn(&install_exe.step); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + const run_step = b.step("run", "Run punkres"); + run_step.dependOn(&run_cmd.step); + + const test_names = [_][]const u8{ "pe", "rsrc", "ico", "overlay", "punkres" }; + const test_step = b.step("test", "Run punkres tests"); + test_step.dependOn(b.getInstallStep()); + inline for (test_names) |tn| { + const mod = b.createModule(.{ + .root_source_file = b.path("./src/" ++ tn ++ "_test.zig"), + .target = target, + .optimize = mode, + }); + const t = b.addTest(.{ + .name = "test_" ++ tn, + .root_module = mod, + }); + const run_t = b.addRunArtifact(t); + //the CLI suite drives the installed binary from zig-out/bin - the run + //itself must wait for install (a step's own install dependency does + //NOT order sibling runs; punkzip build-graph race, 2026-07-27) + if (std.mem.eql(u8, tn, "punkres")) { + run_t.step.dependOn(b.getInstallStep()); + } + test_step.dependOn(&t.step); + test_step.dependOn(&run_t.step); + } +} diff --git a/src/tools/punkres/build.zig.zon b/src/tools/punkres/build.zig.zon new file mode 100644 index 00000000..7c1b397e --- /dev/null +++ b/src/tools/punkres/build.zig.zon @@ -0,0 +1,9 @@ +.{ + .name = .punkres, + .version = "0.1.0", + .fingerprint = 0x663d2953305aee5d, + .minimum_zig_version = "0.16.0", + .paths = .{""}, + //no dependencies - keep it that way if possible (offline-buildable, single-licence-story vendoring) + .dependencies = .{}, +} diff --git a/src/tools/punkres/src/ico.zig b/src/tools/punkres/src/ico.zig new file mode 100644 index 00000000..1d166a27 --- /dev/null +++ b/src/tools/punkres/src/ico.zig @@ -0,0 +1,158 @@ +//punkres .ico handling: parse an icon file into its images, build the +//RT_GROUP_ICON payload (GRPICONDIR) referencing RT_ICON resource ids, and +//reassemble a .ico from stamped resources (the read-back instrument). +// +//A .ico ICONDIRENTRY is 16 bytes ending in a 4-byte file offset; the resource +//GRPICONDIRENTRY is 14 bytes ending in a 2-byte icon resource id. Width and +//height bytes are carried as-is in both directions (0 means 256). Image bytes +//are opaque (BMP or PNG both ride through untouched). + +const std = @import("std"); +const mem = std.mem; + +pub const ParseError = error{ + NotIco, + NoImages, + TruncatedIco, + OutOfMemory, +}; + +pub const Image = struct { + width: u8, + height: u8, + color_count: u8, + reserved: u8, + planes: u16, + bit_count: u16, + data: []const u8, +}; + +pub fn parse(alloc: mem.Allocator, data: []const u8) ParseError![]Image { + if (data.len < 6) return error.NotIco; + const reserved = mem.readInt(u16, data[0..2], .little); + const ico_type = mem.readInt(u16, data[2..4], .little); + const count = mem.readInt(u16, data[4..6], .little); + if (reserved != 0 or ico_type != 1) return error.NotIco; + if (count == 0) return error.NoImages; + if (6 + @as(usize, count) * 16 > data.len) return error.TruncatedIco; + var images = try alloc.alloc(Image, count); + var i: usize = 0; + while (i < count) : (i += 1) { + const off = 6 + i * 16; + const bytes_in_res = mem.readInt(u32, data[off + 8 ..][0..4], .little); + const image_offset = mem.readInt(u32, data[off + 12 ..][0..4], .little); + if (@as(u64, image_offset) + bytes_in_res > data.len) return error.TruncatedIco; + images[i] = .{ + .width = data[off], + .height = data[off + 1], + .color_count = data[off + 2], + .reserved = data[off + 3], + .planes = mem.readInt(u16, data[off + 4 ..][0..2], .little), + .bit_count = mem.readInt(u16, data[off + 6 ..][0..2], .little), + .data = data[image_offset..][0..bytes_in_res], + }; + } + return images; +} + +//GRPICONDIR payload for RT_GROUP_ICON: images become RT_ICON ids +//first_id..first_id+n-1 in order +pub fn buildGroupData(alloc: mem.Allocator, images: []const Image, first_id: u16) ![]u8 { + const out = try alloc.alloc(u8, 6 + images.len * 14); + mem.writeInt(u16, out[0..2], 0, .little); + mem.writeInt(u16, out[2..4], 1, .little); + mem.writeInt(u16, out[4..6], @intCast(images.len), .little); + for (images, 0..) |*img, i| { + const off = 6 + i * 14; + out[off] = img.width; + out[off + 1] = img.height; + out[off + 2] = img.color_count; + out[off + 3] = img.reserved; + mem.writeInt(u16, out[off + 4 ..][0..2], img.planes, .little); + mem.writeInt(u16, out[off + 6 ..][0..2], img.bit_count, .little); + mem.writeInt(u32, out[off + 8 ..][0..4], @intCast(img.data.len), .little); + mem.writeInt(u16, out[off + 12 ..][0..2], @intCast(first_id + i), .little); + } + return out; +} + +pub const GroupEntry = struct { + width: u8, + height: u8, + color_count: u8, + reserved: u8, + planes: u16, + bit_count: u16, + bytes_in_res: u32, + icon_id: u16, +}; + +pub const GroupParseError = error{ + NotGroupIcon, + TruncatedGroup, + OutOfMemory, +}; + +pub fn parseGroupData(alloc: mem.Allocator, data: []const u8) GroupParseError![]GroupEntry { + if (data.len < 6) return error.NotGroupIcon; + const reserved = mem.readInt(u16, data[0..2], .little); + const ico_type = mem.readInt(u16, data[2..4], .little); + const count = mem.readInt(u16, data[4..6], .little); + if (reserved != 0 or ico_type != 1) return error.NotGroupIcon; + if (6 + @as(usize, count) * 14 > data.len) return error.TruncatedGroup; + var entries = try alloc.alloc(GroupEntry, count); + var i: usize = 0; + while (i < count) : (i += 1) { + const off = 6 + i * 14; + entries[i] = .{ + .width = data[off], + .height = data[off + 1], + .color_count = data[off + 2], + .reserved = data[off + 3], + .planes = mem.readInt(u16, data[off + 4 ..][0..2], .little), + .bit_count = mem.readInt(u16, data[off + 6 ..][0..2], .little), + .bytes_in_res = mem.readInt(u32, data[off + 8 ..][0..4], .little), + .icon_id = mem.readInt(u16, data[off + 12 ..][0..2], .little), + }; + } + return entries; +} + +//Reassemble a .ico from a parsed group plus a resolver mapping icon resource +//id -> RT_ICON bytes. Actual resource byte length is authoritative for the +//written ICONDIRENTRY (a mismatching bytes_in_res in the group is tolerated +//and reported through the mismatch counter). +pub fn buildIco( + alloc: mem.Allocator, + entries: []const GroupEntry, + resolver: anytype, //fn-object with .lookup(icon_id: u16) ?[]const u8 + mismatches: *usize, +) error{ MissingIconResource, OutOfMemory }![]u8 { + var total: usize = 6 + entries.len * 16; + var i: usize = 0; + while (i < entries.len) : (i += 1) { + const data = resolver.lookup(entries[i].icon_id) orelse return error.MissingIconResource; + total += data.len; + } + const out = try alloc.alloc(u8, total); + mem.writeInt(u16, out[0..2], 0, .little); + mem.writeInt(u16, out[2..4], 1, .little); + mem.writeInt(u16, out[4..6], @intCast(entries.len), .little); + var cur: usize = 6 + entries.len * 16; + for (entries, 0..) |*e, idx| { + const data = resolver.lookup(e.icon_id).?; + if (data.len != e.bytes_in_res) mismatches.* += 1; + const off = 6 + idx * 16; + out[off] = e.width; + out[off + 1] = e.height; + out[off + 2] = e.color_count; + out[off + 3] = e.reserved; + mem.writeInt(u16, out[off + 4 ..][0..2], e.planes, .little); + mem.writeInt(u16, out[off + 6 ..][0..2], e.bit_count, .little); + mem.writeInt(u32, out[off + 8 ..][0..4], @intCast(data.len), .little); + mem.writeInt(u32, out[off + 12 ..][0..4], @intCast(cur), .little); + @memcpy(out[cur..][0..data.len], data); + cur += data.len; + } + return out; +} diff --git a/src/tools/punkres/src/ico_test.zig b/src/tools/punkres/src/ico_test.zig new file mode 100644 index 00000000..b1ec974a --- /dev/null +++ b/src/tools/punkres/src/ico_test.zig @@ -0,0 +1,78 @@ +const std = @import("std"); +const mem = std.mem; +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; +const expectError = std.testing.expectError; + +const ico = @import("./ico.zig"); +const synthpe = @import("./synthpe.zig"); + +test "parse synthetic ico" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const data = try synthpe.buildTestIco(alloc, 3); + const images = try ico.parse(alloc, data); + try expectEqual(@as(usize, 3), images.len); + try expectEqual(@as(u8, 16), images[0].width); + try expectEqual(@as(u8, 32), images[1].width); + try expectEqual(@as(u8, 48), images[2].width); + try expectEqual(@as(usize, 40), images[0].data.len); + try expectEqual(@as(usize, 57), images[1].data.len); + try expectEqual(@as(usize, 74), images[2].data.len); + try expectEqual(@as(u16, 32), images[0].bit_count); +} + +test "parse rejects non-ico" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + try expectError(error.NotIco, ico.parse(alloc, "xx")); + try expectError(error.NotIco, ico.parse(alloc, &[_]u8{ 0, 0, 2, 0, 1, 0 })); //type 2 = cursor + try expectError(error.NoImages, ico.parse(alloc, &[_]u8{ 0, 0, 1, 0, 0, 0 })); + //count says 1 but no directory entry follows + try expectError(error.TruncatedIco, ico.parse(alloc, &[_]u8{ 0, 0, 1, 0, 1, 0 })); +} + +const MapResolver = struct { + images: []const ico.Image, + pub fn lookup(self: *const MapResolver, icon_id: u16) ?[]const u8 { + if (icon_id == 0 or icon_id > self.images.len) return null; + return self.images[icon_id - 1].data; + } +}; + +test "group build + ico reassembly roundtrip is byte-identical" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const data = try synthpe.buildTestIco(alloc, 4); + const images = try ico.parse(alloc, data); + const group = try ico.buildGroupData(alloc, images, 1); + try expectEqual(@as(usize, 6 + 4 * 14), group.len); + const entries = try ico.parseGroupData(alloc, group); + try expectEqual(@as(usize, 4), entries.len); + try expectEqual(@as(u16, 1), entries[0].icon_id); + try expectEqual(@as(u16, 4), entries[3].icon_id); + try expectEqual(@as(u32, 40), entries[0].bytes_in_res); + const resolver = MapResolver{ .images = images }; + var mismatches: usize = 0; + const rebuilt = try ico.buildIco(alloc, entries, &resolver, &mismatches); + try expectEqual(@as(usize, 0), mismatches); + //buildTestIco packs images contiguously in order after the directory - + //exactly the layout buildIco emits - so the roundtrip is byte-identical + try expect(mem.eql(u8, data, rebuilt)); +} + +test "buildIco reports missing icon resources" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const data = try synthpe.buildTestIco(alloc, 2); + const images = try ico.parse(alloc, data); + const group = try ico.buildGroupData(alloc, images, 1); + const entries = try ico.parseGroupData(alloc, group); + const short = MapResolver{ .images = images[0..1] }; + var mismatches: usize = 0; + try expectError(error.MissingIconResource, ico.buildIco(alloc, entries, &short, &mismatches)); +} diff --git a/src/tools/punkres/src/overlay.zig b/src/tools/punkres/src/overlay.zig new file mode 100644 index 00000000..0287e0a6 --- /dev/null +++ b/src/tools/punkres/src/overlay.zig @@ -0,0 +1,181 @@ +//punkres overlay analysis: classify the bytes appended after a PE's last +//section, and - for zip overlays - determine the member-offset convention +//(archive-relative vs file-relative), which decides whether the overlay can be +//moved safely when a resource section is appended. +// +//An ARCHIVE-relative zip records member offsets relative to its own start, so +//shifting the whole overlay preserves it verbatim. A FILE-relative zip records +//absolute file positions; moving it requires rewriting every central-directory +//local-header offset and the EOCD directory offset by the shift delta +//(punkshell goal G-128: refused by default, consent flag applies the fix-up +//preserving the non-standard convention). zip64 and multi-disk archives are +//always refused. Non-zip overlay bytes are opaque: punkres cannot know their +//offset semantics, so moving them also needs explicit consent. +// +//No decompression happens here - member payload verification is the caller's +//business (punk::zip / punkzip in the punkshell tree). + +const std = @import("std"); +const mem = std.mem; + +pub const EOCD_SIG: u32 = 0x06054b50; +pub const CDH_SIG: u32 = 0x02014b50; +pub const LFH_SIG: u32 = 0x04034b50; +pub const ZIP64_LOCATOR_SIG: u32 = 0x07064b50; + +pub const AnalyzeError = error{ + Zip64Unsupported, + MultiDiskUnsupported, + OutOfMemory, +}; + +pub const ZipStyle = enum { archive, file }; + +pub const ZipInfo = struct { + //all positions are absolute offsets into the analyzed FILE + eocd_pos: usize, + cd_pos: usize, //actual central directory position + cd_offset_recorded: u32, + cd_size: u32, + member_count: u16, + comment_len: u16, + base: usize, //derived archive base: actual_cd_pos - cd_offset_recorded + style: ZipStyle, + //absolute positions of each CDH local-header-offset field (for fix-up) + offset_field_positions: []usize, + //recorded local-header offsets, parallel to offset_field_positions + recorded_offsets: []u32, + trailing_bytes: usize, //bytes after EOCD record end (unexpected for kits) +}; + +pub const Kind = union(enum) { + none, //no overlay bytes at all + zip: ZipInfo, + data: usize, //opaque non-zip overlay of this many bytes +}; + +//Analyze the overlay region file[overlay_offset..file_end). file_end is a +//parameter (rather than file.len) so a stripped trailing certificate table can +//be excluded by the caller. +pub fn analyze(alloc: mem.Allocator, file: []const u8, overlay_offset: usize, file_end: usize) AnalyzeError!Kind { + std.debug.assert(file_end <= file.len); + if (overlay_offset >= file_end) return .none; + const region_len = file_end - overlay_offset; + + //EOCD scan: backwards over at most 64KB+22 from file_end, preferring a + //candidate whose comment length lands exactly on file_end + const scan_max: usize = @min(region_len, 65535 + 22); + if (scan_max < 22) return .{ .data = region_len }; + var exact: ?usize = null; + var tolerant: ?usize = null; + var pos: usize = file_end - 22; + const scan_floor = file_end - scan_max; + while (true) { + if (mem.readInt(u32, file[pos..][0..4], .little) == EOCD_SIG) { + const comment_len = mem.readInt(u16, file[pos + 20 ..][0..2], .little); + const rec_end = pos + 22 + comment_len; + if (rec_end == file_end) { + exact = pos; + break; //closest-to-end exact match wins + } else if (rec_end < file_end and tolerant == null) { + tolerant = pos; + } + } + if (pos == scan_floor) break; + pos -= 1; + } + const eocd_pos = exact orelse (tolerant orelse return .{ .data = region_len }); + + const disk_num = mem.readInt(u16, file[eocd_pos + 4 ..][0..2], .little); + const cd_start_disk = mem.readInt(u16, file[eocd_pos + 6 ..][0..2], .little); + const entries_this_disk = mem.readInt(u16, file[eocd_pos + 8 ..][0..2], .little); + const entries_total = mem.readInt(u16, file[eocd_pos + 10 ..][0..2], .little); + const cd_size = mem.readInt(u32, file[eocd_pos + 12 ..][0..4], .little); + const cd_offset_recorded = mem.readInt(u32, file[eocd_pos + 16 ..][0..4], .little); + const comment_len = mem.readInt(u16, file[eocd_pos + 20 ..][0..2], .little); + + //zip64: explicit locator record, or sentinel values in the EOCD + if (eocd_pos >= 20 and mem.readInt(u32, file[eocd_pos - 20 ..][0..4], .little) == ZIP64_LOCATOR_SIG) + return error.Zip64Unsupported; + if (entries_this_disk == 0xffff or entries_total == 0xffff or + cd_size == 0xffffffff or cd_offset_recorded == 0xffffffff) + return error.Zip64Unsupported; + if (disk_num != 0 or cd_start_disk != 0 or entries_this_disk != entries_total) + return error.MultiDiskUnsupported; + + //derive the archive base from where the central directory actually sits + if (cd_size > eocd_pos) return .{ .data = region_len }; + const cd_pos = eocd_pos - cd_size; + if (cd_offset_recorded > cd_pos) return .{ .data = region_len }; + const base = cd_pos - cd_offset_recorded; + //a zip based before the overlay start would mean members inside the PE + //image proper - not an appended payload we understand; treat as opaque. + //base == 0 cannot be a true archive start here (the PE image precedes the + //overlay), so it identifies the file-relative offset convention. + const style: ZipStyle = if (base == 0) .file else .archive; + if (base != 0 and base < overlay_offset) return .{ .data = region_len }; + + //walk the central directory + var offset_positions = try alloc.alloc(usize, entries_total); + var recorded = try alloc.alloc(u32, entries_total); + var p = cd_pos; + var i: usize = 0; + while (i < entries_total) : (i += 1) { + if (p + 46 > eocd_pos) return .{ .data = region_len }; + if (mem.readInt(u32, file[p..][0..4], .little) != CDH_SIG) return .{ .data = region_len }; + const comp_size = mem.readInt(u32, file[p + 20 ..][0..4], .little); + const uncomp_size = mem.readInt(u32, file[p + 24 ..][0..4], .little); + const name_len = mem.readInt(u16, file[p + 28 ..][0..2], .little); + const extra_len = mem.readInt(u16, file[p + 30 ..][0..2], .little); + const cmt_len = mem.readInt(u16, file[p + 32 ..][0..2], .little); + const lfh_offset = mem.readInt(u32, file[p + 42 ..][0..4], .little); + if (comp_size == 0xffffffff or uncomp_size == 0xffffffff or lfh_offset == 0xffffffff) + return error.Zip64Unsupported; + offset_positions[i] = p + 42; + recorded[i] = lfh_offset; + p += 46 + @as(usize, name_len) + extra_len + cmt_len; + } + if (p != eocd_pos) return .{ .data = region_len }; + + //spot-check the base derivation: the first member's local header must be + //where the recorded offset (resolved through the base) says it is + if (entries_total > 0) { + const lfh_abs = base + @as(usize, recorded[0]); + if (lfh_abs + 4 > file_end) return .{ .data = region_len }; + if (mem.readInt(u32, file[lfh_abs..][0..4], .little) != LFH_SIG) return .{ .data = region_len }; + } + + return .{ .zip = .{ + .eocd_pos = eocd_pos, + .cd_pos = cd_pos, + .cd_offset_recorded = cd_offset_recorded, + .cd_size = cd_size, + .member_count = entries_total, + .comment_len = comment_len, + .base = base, + .style = style, + .offset_field_positions = offset_positions, + .recorded_offsets = recorded, + .trailing_bytes = file_end - (eocd_pos + 22 + comment_len), + } }; +} + +pub const ShiftError = error{OffsetOverflow}; + +//Apply the file-relative fix-up to an overlay COPY that is about to be written +//at a new position: every recorded local-header offset and the EOCD directory +//offset move by delta. overlay_buf holds the overlay bytes only; zi positions +//are absolute in the ORIGINAL file, so old_overlay_offset rebases them. +pub fn applyFileRelativeShift(overlay_buf: []u8, zi: *const ZipInfo, old_overlay_offset: usize, delta: i64) ShiftError!void { + for (zi.offset_field_positions, zi.recorded_offsets) |field_pos, old_val| { + const shifted: i64 = @as(i64, old_val) + delta; + if (shifted < 0 or shifted > 0xfffffffe) return error.OffsetOverflow; + const rel = field_pos - old_overlay_offset; + mem.writeInt(u32, overlay_buf[rel..][0..4], @intCast(shifted), .little); + } + const eocd_field_abs = zi.eocd_pos + 16; + const shifted_cd: i64 = @as(i64, zi.cd_offset_recorded) + delta; + if (shifted_cd < 0 or shifted_cd > 0xfffffffe) return error.OffsetOverflow; + const rel = eocd_field_abs - old_overlay_offset; + mem.writeInt(u32, overlay_buf[rel..][0..4], @intCast(shifted_cd), .little); +} diff --git a/src/tools/punkres/src/overlay_test.zig b/src/tools/punkres/src/overlay_test.zig new file mode 100644 index 00000000..1484a281 --- /dev/null +++ b/src/tools/punkres/src/overlay_test.zig @@ -0,0 +1,148 @@ +const std = @import("std"); +const mem = std.mem; +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; +const expectError = std.testing.expectError; + +const overlay = @import("./overlay.zig"); +const synthpe = @import("./synthpe.zig"); + +const ENTRIES = [_]synthpe.ZipEntry{ + .{ .name = "hello.txt", .data = "hello punkres" }, + .{ .name = "dir/file.bin", .data = "\x00\x01\x02\x03\x04\x05\x06\x07" }, + .{ .name = "third", .data = "the third member payload" }, +}; + +fn prefixed(alloc: mem.Allocator, prefix_len: usize, zip_bytes: []const u8) ![]u8 { + const out = try alloc.alloc(u8, prefix_len + zip_bytes.len); + @memset(out[0..prefix_len], 0x5a); + @memcpy(out[prefix_len..], zip_bytes); + return out; +} + +test "empty overlay classifies none" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const file = try prefixed(alloc, 100, ""); + const kind = try overlay.analyze(alloc, file, 100, file.len); + try expect(kind == .none); +} + +test "non-zip overlay classifies data" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const file = try prefixed(alloc, 100, "just some trailing bytes that are not a zip archive at all............"); + const kind = try overlay.analyze(alloc, file, 100, file.len); + try expect(kind == .data); + try expectEqual(@as(usize, 70), kind.data); +} + +test "archive-relative zip overlay" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const zip_bytes = try synthpe.buildStoredZip(alloc, &ENTRIES, .archive, 0, "a comment"); + const file = try prefixed(alloc, 1000, zip_bytes); + const kind = try overlay.analyze(alloc, file, 1000, file.len); + try expect(kind == .zip); + const zi = kind.zip; + try expectEqual(overlay.ZipStyle.archive, zi.style); + try expectEqual(@as(usize, 1000), zi.base); + try expectEqual(@as(u16, 3), zi.member_count); + try expectEqual(@as(u16, 9), zi.comment_len); + try expectEqual(@as(usize, 0), zi.trailing_bytes); +} + +test "file-relative zip overlay detected via base 0" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const zip_bytes = try synthpe.buildStoredZip(alloc, &ENTRIES, .file, 1000, ""); + const file = try prefixed(alloc, 1000, zip_bytes); + const kind = try overlay.analyze(alloc, file, 1000, file.len); + try expect(kind == .zip); + const zi = kind.zip; + try expectEqual(overlay.ZipStyle.file, zi.style); + try expectEqual(@as(usize, 0), zi.base); + try expectEqual(@as(u16, 3), zi.member_count); + //recorded offsets are absolute file positions + try expectEqual(@as(u32, 1000), zi.recorded_offsets[0]); +} + +test "zip64 sentinels and locator are refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const zip_bytes = try synthpe.buildStoredZip(alloc, &ENTRIES, .archive, 0, ""); + //EOCD cd_offset sentinel + const f1 = try prefixed(alloc, 200, zip_bytes); + const eocd1 = f1.len - 22; + mem.writeInt(u32, f1[eocd1 + 16 ..][0..4], 0xffffffff, .little); + try expectError(error.Zip64Unsupported, overlay.analyze(alloc, f1, 200, f1.len)); + //zip64 EOCD locator signature right before the EOCD + const f2 = try alloc.alloc(u8, 200 + zip_bytes.len + 20); + @memset(f2[0..200], 0); + @memcpy(f2[200..][0..zip_bytes.len], zip_bytes); + //move EOCD to the end, put a locator before it + const eocd_src = 200 + zip_bytes.len - 22; + const locator_pos = f2.len - 42; + mem.copyBackwards(u8, f2[locator_pos + 20 ..][0..22], f2[eocd_src..][0..22]); + mem.writeInt(u32, f2[locator_pos..][0..4], overlay.ZIP64_LOCATOR_SIG, .little); + try expectError(error.Zip64Unsupported, overlay.analyze(alloc, f2, 200, f2.len)); +} + +test "multi-disk archives are refused" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const zip_bytes = try synthpe.buildStoredZip(alloc, &ENTRIES, .archive, 0, ""); + const file = try prefixed(alloc, 200, zip_bytes); + const eocd = file.len - 22; + mem.writeInt(u16, file[eocd + 4 ..][0..2], 1, .little); //disk_num + try expectError(error.MultiDiskUnsupported, overlay.analyze(alloc, file, 200, file.len)); +} + +test "file-relative shift rewrites offsets so the moved overlay re-analyzes clean" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const zip_bytes = try synthpe.buildStoredZip(alloc, &ENTRIES, .file, 1000, "trail"); + const f1 = try prefixed(alloc, 1000, zip_bytes); + const k1 = try overlay.analyze(alloc, f1, 1000, f1.len); + const zi = k1.zip; + //simulate the overlay moving forward by 512 bytes + const delta: i64 = 512; + const moved = try alloc.alloc(u8, f1.len + 512); + @memset(moved[0..1512], 0x5a); + @memcpy(moved[1512..], zip_bytes); + try overlay.applyFileRelativeShift(moved[1512..], &zi, 1000, delta); + const k2 = try overlay.analyze(alloc, moved, 1512, moved.len); + try expect(k2 == .zip); + const zi2 = k2.zip; + try expectEqual(overlay.ZipStyle.file, zi2.style); + try expectEqual(@as(u16, 3), zi2.member_count); + try expectEqual(@as(u32, 1512), zi2.recorded_offsets[0]); + //every rewritten offset lands on a local file header signature + for (zi2.recorded_offsets) |off| { + try expectEqual(overlay.LFH_SIG, mem.readInt(u32, moved[off..][0..4], .little)); + } + //and a negative shift going below zero is refused + const bad = try alloc.dupe(u8, zip_bytes); + try expectError(error.OffsetOverflow, overlay.applyFileRelativeShift(bad, &zi, 1000, -2000)); +} + +test "eocd with trailing junk is tolerated and reported" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const zip_bytes = try synthpe.buildStoredZip(alloc, &ENTRIES, .archive, 0, ""); + const with_junk = try alloc.alloc(u8, zip_bytes.len + 7); + @memcpy(with_junk[0..zip_bytes.len], zip_bytes); + @memset(with_junk[zip_bytes.len..], 0xee); + const file = try prefixed(alloc, 300, with_junk); + const kind = try overlay.analyze(alloc, file, 300, file.len); + try expect(kind == .zip); + try expectEqual(@as(usize, 7), kind.zip.trailing_bytes); +} diff --git a/src/tools/punkres/src/pe.zig b/src/tools/punkres/src/pe.zig new file mode 100644 index 00000000..f4fa9cf9 --- /dev/null +++ b/src/tools/punkres/src/pe.zig @@ -0,0 +1,342 @@ +//punkres PE image model: parse headers/sections/data directories of a finished +//PE (PE32 and PE32+), locate the overlay boundary, compute the PE checksum, and +//assemble a stamped output image with a replaced resource section. +// +//Everything reads and writes explicit little-endian so the tool behaves +//identically from any build host (the whole point of punkres - see punkshell +//goal G-128). The input file is held fully in memory; kits are tens of MB. + +const std = @import("std"); +const mem = std.mem; + +pub const DATA_DIR_EXPORT = 0; +pub const DATA_DIR_IMPORT = 1; +pub const DATA_DIR_RESOURCE = 2; +pub const DATA_DIR_EXCEPTION = 3; +pub const DATA_DIR_SECURITY = 4; //file OFFSET, not an RVA +pub const DATA_DIR_BASERELOC = 5; + +pub const SCN_CNT_INITIALIZED_DATA: u32 = 0x00000040; +pub const SCN_MEM_READ: u32 = 0x40000000; + +//section name punkres uses for an appended resource section - distinct from +//".rsrc" so provenance is visible in a section listing and convergence +//detection stays honest (see placementFor) +pub const APPENDED_SECTION_NAME = ".prsrc"; + +pub const ParseError = error{ + NotMz, + NotPe, + UnsupportedOptionalHeaderMagic, + TruncatedHeader, + SectionOutsideFile, + TooManyDataDirectories, + OutOfMemory, +}; + +pub const Section = struct { + name: [8]u8, + virtual_size: u32, + virtual_address: u32, + size_of_raw_data: u32, + pointer_to_raw_data: u32, + characteristics: u32, + + pub fn nameSlice(self: *const Section) []const u8 { + return mem.sliceTo(&self.name, 0); + } + pub fn rawEnd(self: *const Section) usize { + if (self.size_of_raw_data == 0) return 0; + return @as(usize, self.pointer_to_raw_data) + self.size_of_raw_data; + } +}; + +pub const DataDir = struct { rva: u32, size: u32 }; + +pub const Pe = struct { + data: []const u8, + is_pe32plus: bool, + machine: u16, + num_sections: u16, + coff_offset: usize, //of the COFF file header (after the PE\0\0 signature) + opt_offset: usize, + opt_size: u16, + sect_table_offset: usize, + file_alignment: u32, + section_alignment: u32, + size_of_headers: u32, + size_of_image: u32, + checksum_offset: usize, + num_data_dirs: u32, + data_dir_offset: usize, + sections: []Section, + overlay_offset: usize, + + pub fn parse(alloc: mem.Allocator, data: []const u8) ParseError!Pe { + if (data.len < 0x40) return error.NotMz; + if (!(data[0] == 'M' and data[1] == 'Z')) return error.NotMz; + const e_lfanew = mem.readInt(u32, data[0x3c..][0..4], .little); + if (@as(u64, e_lfanew) + 4 + 20 > data.len) return error.NotPe; + if (!mem.eql(u8, data[e_lfanew..][0..4], "PE\x00\x00")) return error.NotPe; + const coff_offset: usize = e_lfanew + 4; + const machine = mem.readInt(u16, data[coff_offset..][0..2], .little); + const num_sections = mem.readInt(u16, data[coff_offset + 2 ..][0..2], .little); + const opt_size = mem.readInt(u16, data[coff_offset + 16 ..][0..2], .little); + const opt_offset = coff_offset + 20; + if (opt_offset + opt_size > data.len) return error.TruncatedHeader; + if (opt_size < 2) return error.TruncatedHeader; + const magic = mem.readInt(u16, data[opt_offset..][0..2], .little); + const is_pe32plus = switch (magic) { + 0x10b => false, + 0x20b => true, + else => return error.UnsupportedOptionalHeaderMagic, + }; + //fixed-field offsets shared by PE32 and PE32+ (they diverge after + //BaseOfCode/ImageBase but re-align for the fields we need) + const section_alignment = mem.readInt(u32, data[opt_offset + 32 ..][0..4], .little); + const file_alignment = mem.readInt(u32, data[opt_offset + 36 ..][0..4], .little); + const size_of_image = mem.readInt(u32, data[opt_offset + 56 ..][0..4], .little); + const size_of_headers = mem.readInt(u32, data[opt_offset + 60 ..][0..4], .little); + const checksum_offset = opt_offset + 64; + const num_dirs_offset: usize = if (is_pe32plus) opt_offset + 108 else opt_offset + 92; + const data_dir_offset: usize = if (is_pe32plus) opt_offset + 112 else opt_offset + 96; + if (num_dirs_offset + 4 > data.len) return error.TruncatedHeader; + const num_data_dirs = mem.readInt(u32, data[num_dirs_offset..][0..4], .little); + if (num_data_dirs > 16) return error.TooManyDataDirectories; + if (data_dir_offset + @as(usize, num_data_dirs) * 8 > data.len) return error.TruncatedHeader; + const sect_table_offset = opt_offset + opt_size; + if (sect_table_offset + @as(usize, num_sections) * 40 > data.len) return error.TruncatedHeader; + + var sections = try alloc.alloc(Section, num_sections); + var overlay_offset: usize = size_of_headers; + var i: usize = 0; + while (i < num_sections) : (i += 1) { + const off = sect_table_offset + i * 40; + var s: Section = undefined; + @memcpy(&s.name, data[off..][0..8]); + s.virtual_size = mem.readInt(u32, data[off + 8 ..][0..4], .little); + s.virtual_address = mem.readInt(u32, data[off + 12 ..][0..4], .little); + s.size_of_raw_data = mem.readInt(u32, data[off + 16 ..][0..4], .little); + s.pointer_to_raw_data = mem.readInt(u32, data[off + 20 ..][0..4], .little); + s.characteristics = mem.readInt(u32, data[off + 36 ..][0..4], .little); + sections[i] = s; + const rend = s.rawEnd(); + if (rend > data.len) return error.SectionOutsideFile; + if (rend > overlay_offset) overlay_offset = rend; + } + if (overlay_offset > data.len) return error.SectionOutsideFile; + + return Pe{ + .data = data, + .is_pe32plus = is_pe32plus, + .machine = machine, + .num_sections = num_sections, + .coff_offset = coff_offset, + .opt_offset = opt_offset, + .opt_size = opt_size, + .sect_table_offset = sect_table_offset, + .file_alignment = file_alignment, + .section_alignment = section_alignment, + .size_of_headers = size_of_headers, + .size_of_image = size_of_image, + .checksum_offset = checksum_offset, + .num_data_dirs = num_data_dirs, + .data_dir_offset = data_dir_offset, + .sections = sections, + .overlay_offset = overlay_offset, + }; + } + + pub fn dataDir(self: *const Pe, index: u32) DataDir { + if (index >= self.num_data_dirs) return .{ .rva = 0, .size = 0 }; + const off = self.data_dir_offset + index * 8; + return .{ + .rva = mem.readInt(u32, self.data[off..][0..4], .little), + .size = mem.readInt(u32, self.data[off + 4 ..][0..4], .little), + }; + } + + pub fn sectionContaining(self: *const Pe, rva: u32) ?*const Section { + for (self.sections) |*s| { + const vsize = if (s.virtual_size != 0) s.virtual_size else s.size_of_raw_data; + if (rva >= s.virtual_address and rva < s.virtual_address + vsize) return s; + } + return null; + } + + //Map an RVA to a file offset through the section table. Returns null for + //RVAs with no raw backing (uninitialized data, out of range). + pub fn rvaToOffset(self: *const Pe, rva: u32) ?usize { + const s = self.sectionContaining(rva) orelse return null; + const delta = rva - s.virtual_address; + if (delta >= s.size_of_raw_data) return null; + return @as(usize, s.pointer_to_raw_data) + delta; + } +}; + +pub fn alignUp(v: usize, alignment: usize) usize { + if (alignment == 0) return v; + return (v + alignment - 1) / alignment * alignment; +} + +//MS PE checksum (imagehlp CheckSumMappedFile algorithm): 16-bit one's-complement +//style sum over the whole file with the 4-byte CheckSum field treated as zero, +//plus the file length. +pub fn checksum(data: []const u8, checksum_field_offset: usize) u32 { + var sum: u32 = 0; + var i: usize = 0; + while (i + 1 < data.len) : (i += 2) { + if (i == checksum_field_offset or i == checksum_field_offset + 2) continue; + const w = mem.readInt(u16, data[i..][0..2], .little); + sum += w; + sum = (sum >> 16) + (sum & 0xffff); + } + if (i < data.len) { + sum += data[i]; + sum = (sum >> 16) + (sum & 0xffff); + } + sum = (sum >> 16) + (sum & 0xffff); + return sum + @as(u32, @intCast(data.len & 0xffffffff)); +} + +pub const Placement = union(enum) { + //resource data directory points at the start of the LAST section (by RVA) + //whose raw data ends exactly at the overlay boundary: rebuild that section + //in place. This is both the natural convergence path for a section punkres + //appended earlier and the in-place path for a foreign PE whose resource + //section happens to be last. + rebuild: usize, //section index + //otherwise: append a new section (the punkshell runtime shape - .rsrc is + //followed by .reloc, so growing in place is unavailable) + append, +}; + +pub fn placementFor(pe: *const Pe) Placement { + const rdir = pe.dataDir(DATA_DIR_RESOURCE); + if (rdir.rva == 0) return .append; + var last_by_rva: ?usize = null; + var max_va: u32 = 0; + for (pe.sections, 0..) |*s, idx| { + if (s.virtual_address >= max_va) { + max_va = s.virtual_address; + last_by_rva = idx; + } + } + const idx = last_by_rva orelse return .append; + const s = &pe.sections[idx]; + if (s.virtual_address != rdir.rva) return .append; + if (s.rawEnd() != pe.overlay_offset or s.size_of_raw_data == 0) return .append; + return .{ .rebuild = idx }; +} + +pub const StampPlan = struct { + placement: Placement, + new_rva: u32, //where serialized resource data will live +}; + +pub fn planStamp(pe: *const Pe) StampPlan { + const placement = placementFor(pe); + switch (placement) { + .rebuild => |idx| return .{ .placement = placement, .new_rva = pe.sections[idx].virtual_address }, + .append => { + var max_end: u32 = pe.size_of_headers; + for (pe.sections) |*s| { + const vsize = if (s.virtual_size != 0) s.virtual_size else s.size_of_raw_data; + const e = s.virtual_address + vsize; + if (e > max_end) max_end = e; + } + const new_rva: u32 = @intCast(alignUp(max_end, pe.section_alignment)); + return .{ .placement = placement, .new_rva = new_rva }; + }, + } +} + +pub const AssembleError = error{ + NoRoomForSectionHeader, + ResourceDataTooLarge, + OutOfMemory, +}; + +pub const Assembled = struct { + out: []u8, //final image WITHOUT overlay appended yet + new_overlay_offset: usize, + appended_section: bool, +}; + +//Build the stamped image (headers + sections + new resource data), leaving the +//overlay to be appended by the caller (which may need to fix zip offsets using +//the delta between old and new overlay offsets first). Checksum is NOT yet +//computed - call finalizeChecksum on the complete file (image + overlay). +pub fn assemble(alloc: mem.Allocator, pe: *const Pe, plan: StampPlan, rsrc_bytes: []const u8) AssembleError!Assembled { + if (rsrc_bytes.len > 0xffffffff) return error.ResourceDataTooLarge; + const raw_size = alignUp(rsrc_bytes.len, pe.file_alignment); + switch (plan.placement) { + .rebuild => |idx| { + const s = &pe.sections[idx]; + const keep = @as(usize, s.pointer_to_raw_data); + const out = try alloc.alloc(u8, keep + raw_size); + @memcpy(out[0..keep], pe.data[0..keep]); + @memcpy(out[keep..][0..rsrc_bytes.len], rsrc_bytes); + @memset(out[keep + rsrc_bytes.len ..], 0); + //patch this section's header + const hoff = pe.sect_table_offset + idx * 40; + mem.writeInt(u32, out[hoff + 8 ..][0..4], @intCast(rsrc_bytes.len), .little); //VirtualSize + mem.writeInt(u32, out[hoff + 16 ..][0..4], @intCast(raw_size), .little); //SizeOfRawData + patchCommon(out, pe, plan.new_rva, rsrc_bytes.len, @intCast(raw_size), s.size_of_raw_data); + return .{ .out = out, .new_overlay_offset = keep + raw_size, .appended_section = false }; + }, + .append => { + const hdr_end = pe.sect_table_offset + (@as(usize, pe.num_sections) + 1) * 40; + if (hdr_end > pe.size_of_headers) return error.NoRoomForSectionHeader; + const keep = pe.overlay_offset; + const out = try alloc.alloc(u8, keep + raw_size); + @memcpy(out[0..keep], pe.data[0..keep]); + @memcpy(out[keep..][0..rsrc_bytes.len], rsrc_bytes); + @memset(out[keep + rsrc_bytes.len ..], 0); + //new section header + const hoff = pe.sect_table_offset + @as(usize, pe.num_sections) * 40; + @memset(out[hoff..][0..40], 0); + const name = APPENDED_SECTION_NAME; + @memcpy(out[hoff..][0..name.len], name); + mem.writeInt(u32, out[hoff + 8 ..][0..4], @intCast(rsrc_bytes.len), .little); //VirtualSize + mem.writeInt(u32, out[hoff + 12 ..][0..4], plan.new_rva, .little); //VirtualAddress + mem.writeInt(u32, out[hoff + 16 ..][0..4], @intCast(raw_size), .little); //SizeOfRawData + mem.writeInt(u32, out[hoff + 20 ..][0..4], @intCast(keep), .little); //PointerToRawData + mem.writeInt(u32, out[hoff + 36 ..][0..4], SCN_CNT_INITIALIZED_DATA | SCN_MEM_READ, .little); + //section count + mem.writeInt(u16, out[pe.coff_offset + 2 ..][0..2], pe.num_sections + 1, .little); + patchCommon(out, pe, plan.new_rva, rsrc_bytes.len, @intCast(raw_size), 0); + return .{ .out = out, .new_overlay_offset = keep + raw_size, .appended_section = true }; + }, + } +} + +fn patchCommon(out: []u8, pe: *const Pe, new_rva: u32, rsrc_len: usize, new_raw_size: u32, replaced_raw_size: u32) void { + //resource data directory + const doff = pe.data_dir_offset + DATA_DIR_RESOURCE * 8; + mem.writeInt(u32, out[doff..][0..4], new_rva, .little); + mem.writeInt(u32, out[doff + 4 ..][0..4], @intCast(rsrc_len), .little); + //SizeOfImage + const new_size_of_image: u32 = @intCast(alignUp(@as(usize, new_rva) + rsrc_len, pe.section_alignment)); + mem.writeInt(u32, out[pe.opt_offset + 56 ..][0..4], new_size_of_image, .little); + //SizeOfInitializedData (informational; keep it honest) + const sid_off = pe.opt_offset + 8; + const old_sid = mem.readInt(u32, out[sid_off..][0..4], .little); + const adjusted = @as(u64, old_sid) + new_raw_size -| @as(u64, replaced_raw_size); + mem.writeInt(u32, out[sid_off..][0..4], @intCast(adjusted & 0xffffffff), .little); +} + +pub fn finalizeChecksum(file_bytes: []u8, pe_checksum_offset: usize) void { + const sum = checksum(file_bytes, pe_checksum_offset); + mem.writeInt(u32, file_bytes[pe_checksum_offset..][0..4], sum, .little); +} + +pub fn machineName(machine: u16) []const u8 { + return switch (machine) { + 0x8664 => "x86_64", + 0x014c => "i386", + 0xaa64 => "aarch64", + 0x01c4 => "armnt", + else => "unknown", + }; +} diff --git a/src/tools/punkres/src/pe_test.zig b/src/tools/punkres/src/pe_test.zig new file mode 100644 index 00000000..99248d62 --- /dev/null +++ b/src/tools/punkres/src/pe_test.zig @@ -0,0 +1,126 @@ +const std = @import("std"); +const mem = std.mem; +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; +const expectError = std.testing.expectError; + +const pemod = @import("./pe.zig"); +const rsrc = @import("./rsrc.zig"); +const synthpe = @import("./synthpe.zig"); + +test "alignUp" { + try expectEqual(@as(usize, 0), pemod.alignUp(0, 512)); + try expectEqual(@as(usize, 512), pemod.alignUp(1, 512)); + try expectEqual(@as(usize, 512), pemod.alignUp(512, 512)); + try expectEqual(@as(usize, 1024), pemod.alignUp(513, 512)); + try expectEqual(@as(usize, 7), pemod.alignUp(7, 0)); +} + +test "checksum hand vectors" { + //64 zero bytes, checksum field at 0: word sum 0 -> checksum = len + var buf = [_]u8{0} ** 64; + try expectEqual(@as(u32, 64), pemod.checksum(&buf, 0)); + //one nonzero word outside the checksum field + mem.writeInt(u16, buf[10..12], 0x0102, .little); + try expectEqual(@as(u32, 64 + 0x0102), pemod.checksum(&buf, 0)); + //fold carry: 0xffff + 0x0002 = 0x10001 -> 0x0002; plus len + var buf2 = [_]u8{0} ** 64; + mem.writeInt(u16, buf2[10..12], 0xffff, .little); + mem.writeInt(u16, buf2[12..14], 0x0002, .little); + try expectEqual(@as(u32, 64 + 2), pemod.checksum(&buf2, 0)); + //bytes inside the checksum field are ignored + var buf3 = [_]u8{0} ** 64; + mem.writeInt(u32, buf3[20..24], 0xdeadbeef, .little); + try expectEqual(@as(u32, 64), pemod.checksum(&buf3, 20)); + //odd trailing byte contributes as a low byte + var buf4 = [_]u8{0} ** 65; + buf4[64] = 0x7f; + try expectEqual(@as(u32, 65 + 0x7f), pemod.checksum(&buf4, 0)); +} + +test "parse rejects non-PE input" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + try expectError(error.NotMz, pemod.Pe.parse(alloc, "this is not a PE file at all, but it is long enough to have a header")); + var mz = [_]u8{0} ** 0x100; + mz[0] = 'M'; + mz[1] = 'Z'; + mem.writeInt(u32, mz[0x3c..][0..4], 0x80, .little); + try expectError(error.NotPe, pemod.Pe.parse(alloc, &mz)); +} + +test "parse synthetic PE" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const img = try synthpe.buildPe(.{ .alloc = alloc }); + const pe = try pemod.Pe.parse(alloc, img); + try expectEqual(true, pe.is_pe32plus); + try expectEqual(@as(u16, 0x8664), pe.machine); + try expectEqual(@as(u16, 3), pe.num_sections); + try expect(mem.eql(u8, pe.sections[0].nameSlice(), ".text")); + try expect(mem.eql(u8, pe.sections[1].nameSlice(), ".rsrc")); + try expect(mem.eql(u8, pe.sections[2].nameSlice(), ".reloc")); + //no overlay: boundary is exactly the file size + try expectEqual(img.len, pe.overlay_offset); + const rdir = pe.dataDir(pemod.DATA_DIR_RESOURCE); + try expectEqual(pe.sections[1].virtual_address, rdir.rva); + //stored checksum is self-consistent + const stored = mem.readInt(u32, img[pe.checksum_offset..][0..4], .little); + try expectEqual(pemod.checksum(img, pe.checksum_offset), stored); + //rva mapping round trip + const off = pe.rvaToOffset(rdir.rva).?; + try expectEqual(@as(usize, pe.sections[1].pointer_to_raw_data), off); +} + +test "planStamp appends when resource section is not last, rebuilds its own appended section" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const img = try synthpe.buildPe(.{ .alloc = alloc }); + const pe = try pemod.Pe.parse(alloc, img); + const plan = pemod.planStamp(&pe); + try expect(plan.placement == .append); + //stamp once: serialize the parsed tree unchanged at the planned rva + var tree = try rsrc.parse(alloc, &pe); + const bytes = try rsrc.serialize(alloc, &tree, plan.new_rva); + const asm1 = try pemod.assemble(alloc, &pe, plan, bytes); + pemod.finalizeChecksum(asm1.out, pe.checksum_offset); + //the stamped image parses, has one more section, and its placement is now rebuild + const pe2 = try pemod.Pe.parse(alloc, asm1.out); + try expectEqual(@as(u16, 4), pe2.num_sections); + try expect(mem.eql(u8, pe2.sections[3].nameSlice(), pemod.APPENDED_SECTION_NAME)); + const rdir2 = pe2.dataDir(pemod.DATA_DIR_RESOURCE); + try expectEqual(pe2.sections[3].virtual_address, rdir2.rva); + const plan2 = pemod.planStamp(&pe2); + try expect(plan2.placement == .rebuild); + try expectEqual(plan.new_rva, plan2.new_rva); + //checksum of stamped output is self-consistent + const stored = mem.readInt(u32, asm1.out[pe2.checksum_offset..][0..4], .little); + try expectEqual(pemod.checksum(asm1.out, pe2.checksum_offset), stored); + //second stamp with identical content is byte-identical (convergence) + var tree2 = try rsrc.parse(alloc, &pe2); + const bytes2 = try rsrc.serialize(alloc, &tree2, plan2.new_rva); + try expect(mem.eql(u8, bytes, bytes2)); + const asm2 = try pemod.assemble(alloc, &pe2, plan2, bytes2); + pemod.finalizeChecksum(asm2.out, pe2.checksum_offset); + try expect(mem.eql(u8, asm1.out, asm2.out)); +} + +test "assemble refuses when the header region has no room for another section header" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const img = try synthpe.buildPe(.{ .alloc = alloc }); + //shrink SizeOfHeaders in a copy so the check trips + const copy = try alloc.dupe(u8, img); + const pe0 = try pemod.Pe.parse(alloc, img); + const so_headers_off = pe0.opt_offset + 60; + const needed = pe0.sect_table_offset + (@as(usize, pe0.num_sections) + 1) * 40; + mem.writeInt(u32, copy[so_headers_off..][0..4], @intCast(needed - 1), .little); + const pe = try pemod.Pe.parse(alloc, copy); + const plan = pemod.planStamp(&pe); + try expect(plan.placement == .append); + try expectError(error.NoRoomForSectionHeader, pemod.assemble(alloc, &pe, plan, "xx")); +} diff --git a/src/tools/punkres/src/punkres.zig b/src/tools/punkres/src/punkres.zig new file mode 100644 index 00000000..b3b768ce --- /dev/null +++ b/src/tools/punkres/src/punkres.zig @@ -0,0 +1,857 @@ +//punkres - portable PE resource stamper for the punkshell project (G-128). +// +//Stamps RT_ICON/RT_GROUP_ICON into a FINISHED PE executable - including one +//carrying an appended zip payload (a punkshell kit) - from any build host, +//with the overlay treated as a first-class thing to preserve. Reads its own +//work back (info/list/extract-ico) so verification needs no Windows host and +//no twapi. +// +//Offset-convention contract (see overlay.zig): archive-relative zip payloads +//are moved verbatim; file-relative ones are REFUSED by default and stamped +//only with -allow-file-relative-shift (offsets rewritten by the shift delta, +//convention preserved); zip64/multi-disk are refused always; non-zip overlay +//bytes need -allow-opaque-overlay. +// +//Exit codes: 0 ok, 1 error, 2 hard refusal (zip64/multi-disk/odd signature), +//3 refusal that a consent flag can lift (stated in the message). +// +//stdout is the parseable product (porcelain formats are pinned by CLI tests); +//diagnostics go to stderr - same contract as punkzip. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const mem = std.mem; + +const pemod = @import("./pe.zig"); +const rsrc = @import("./rsrc.zig"); +const ico = @import("./ico.zig"); +const overlay = @import("./overlay.zig"); + +const allocator = std.heap.page_allocator; +//io implementation from main's Init - set at entry before any other use (single-threaded CLI) +var g_io: Io = undefined; +//stdout with honestly-probed flags - set at entry (see stdout_file) +var g_stdout: Io.File = undefined; + +pub const VERSION = "0.1.0"; + +pub const HEADER = + \\# PUNKRES 0.1.0 -- portable PE resource stamper (punkshell G-128) + \\ +; + +//Io.File.stdout() hardcodes blocking flags, but the stdout HANDLE may really be +//asynchronous (overlapped) - msys/cygwin shells hand children overlapped pipe +//ends - and the Threaded Io blocking write path hits 'unreachable' on +//NtWriteFile PENDING once the pipe buffer fills. Probe the actual handle mode +//once and record it honestly so the Io implementation takes its correct +//alertable-wait nonblocking path. Console handles fail the query and stay +//blocking, which is right for them. (Pattern from punkzip, 2026-07-27.) +fn stdout_file() Io.File { + var f = Io.File.stdout(); + if (builtin.os.tag == .windows) { + const w = std.os.windows; + var mode_info: w.FILE.MODE.INFORMATION = undefined; + var iosb: w.IO_STATUS_BLOCK = undefined; + const rc = w.ntdll.NtQueryInformationFile(f.handle, &iosb, &mode_info, @sizeOf(w.FILE.MODE.INFORMATION), .Mode); + if (rc == .SUCCESS and mode_info.Mode.IO == .ASYNCHRONOUS) { + f.flags.nonblocking = true; + } + } + return f; +} + +fn printf(comptime fmt: []const u8, args: anytype) !void { + const outstr = try std.fmt.allocPrint(allocator, fmt, args); + try g_stdout.writeStreamingAll(g_io, outstr); + //deliberately unfreed - program lifetime +} +fn print(comptime fmt: []const u8) !void { + try printf(fmt, .{}); +} +fn errprintf(comptime fmt: []const u8, args: anytype) void { + std.debug.print("* " ++ fmt, args); +} + +fn read_file(path: []const u8) ![]u8 { + const file: Io.File = Io.Dir.cwd().openFile(g_io, path, .{ .mode = .read_only, .allow_directory = false }) catch |err| { + errprintf("Unable to open file {s}: {}\n", .{ path, err }); + return err; + }; + defer file.close(g_io); + const size: usize = @intCast((try file.stat(g_io)).size); + const contents = try allocator.alloc(u8, size); + const n = try file.readPositionalAll(g_io, contents, 0); + return contents[0..n]; +} + +fn write_file(path: []const u8, data: []const u8) !void { + const file = Io.Dir.cwd().createFile(g_io, path, .{}) catch |err| { + errprintf("Unable to create file {s}: {}\n", .{ path, err }); + return err; + }; + defer file.close(g_io); + try file.writePositionalAll(g_io, data, 0); +} + +//in-place update: write beside the target then rename over it, so a failed run +//never leaves a half-written executable +fn write_file_replacing(path: []const u8, data: []const u8) !void { + const tmp_path = try std.fmt.allocPrint(allocator, "{s}.punkres-tmp", .{path}); + try write_file(tmp_path, data); + Io.Dir.cwd().rename(tmp_path, Io.Dir.cwd(), path, g_io) catch |err| { + errprintf("Unable to replace {s} (target locked by a running process?): {}\n", .{ path, err }); + return err; + }; +} + +fn print_usage(exe: []const u8) !void { + const tail = std.fs.path.basename(exe); + try printf( + \\ + \\Usage: + \\ + \\ {s} info [-porcelain] + \\ {s} list [-porcelain] + \\ {s} extract-ico [-group ] + \\ {s} set-icon [-o ] [-allow-file-relative-shift] + \\ [-allow-opaque-overlay] [-strip-signature] + \\ {s} version + \\ + \\ set-icon replaces ALL RT_ICON/RT_GROUP_ICON resources with the images + \\ of (ids 1..N under the first pre-existing group's name and + \\ language; name 1 / lang 1033 when none), preserving every other + \\ resource type and any appended payload. Without -o the file is + \\ updated in place. Exit 3 = refused, a consent flag applies. + \\ + , .{ tail, tail, tail, tail, tail }); +} + +//--------------------------------------------------------------------------- +//rendering helpers + +fn typeName(id: u32) []const u8 { + return switch (id) { + 1 => "RT_CURSOR", + 2 => "RT_BITMAP", + 3 => "RT_ICON", + 4 => "RT_MENU", + 5 => "RT_DIALOG", + 6 => "RT_STRING", + 7 => "RT_FONTDIR", + 8 => "RT_FONT", + 9 => "RT_ACCELERATOR", + 10 => "RT_RCDATA", + 11 => "RT_MESSAGETABLE", + 12 => "RT_GROUP_CURSOR", + 14 => "RT_GROUP_ICON", + 16 => "RT_VERSION", + 17 => "RT_DLGINCLUDE", + 19 => "RT_PLUGPLAY", + 20 => "RT_VXD", + 21 => "RT_ANICURSOR", + 22 => "RT_ANIICON", + 23 => "RT_HTML", + 24 => "RT_MANIFEST", + else => "-", + }; +} + +//porcelain token for a ResName: decimal id, or s: +//(bytes <= 0x20, '%', >= 0x7f escape as %XX so tokens stay single-word) +fn nameToken(name: rsrc.ResName) ![]const u8 { + switch (name) { + .id => |id| return std.fmt.allocPrint(allocator, "{d}", .{id}), + .name => |units| { + const utf8 = try std.unicode.wtf16LeToWtf8Alloc(allocator, units); + var buf: std.ArrayList(u8) = .empty; + try buf.appendSlice(allocator, "s:"); + for (utf8) |b| { + if (b <= 0x20 or b == '%' or b >= 0x7f) { + var tmp: [3]u8 = undefined; + _ = std.fmt.bufPrint(&tmp, "%{X:0>2}", .{b}) catch unreachable; + try buf.appendSlice(allocator, &tmp); + } else { + try buf.append(allocator, b); + } + } + return buf.items; + }, + } +} + +//human rendering: bare id, or the name in double quotes +fn nameHuman(name: rsrc.ResName) ![]const u8 { + switch (name) { + .id => |id| return std.fmt.allocPrint(allocator, "{d}", .{id}), + .name => |units| { + const utf8 = try std.unicode.wtf16LeToWtf8Alloc(allocator, units); + return std.fmt.allocPrint(allocator, "\"{s}\"", .{utf8}); + }, + } +} + +fn parseNameArg(arg: []const u8) !rsrc.ResName { + if (arg.len > 0) { + var all_digits = true; + for (arg) |c| { + if (c < '0' or c > '9') { + all_digits = false; + break; + } + } + if (all_digits) { + const id = std.fmt.parseInt(u32, arg, 10) catch return error.BadName; + return .{ .id = id }; + } + } + const units = std.unicode.wtf8ToWtf16LeAlloc(allocator, arg) catch return error.BadName; + return .{ .name = units }; +} + +//--------------------------------------------------------------------------- +//overlay classification shared by info and set-icon + +const OverlayReport = struct { + kind: enum { none, zip, data, zip_refused }, + refused_reason: []const u8 = "", + zip: ?overlay.ZipInfo = null, + size: usize, +}; + +fn classifyOverlay(file: []const u8, overlay_offset: usize, file_end: usize) !OverlayReport { + const size = file_end - @min(overlay_offset, file_end); + const kind = overlay.analyze(allocator, file, overlay_offset, file_end) catch |err| switch (err) { + error.Zip64Unsupported => return .{ .kind = .zip_refused, .refused_reason = "zip64", .size = size }, + error.MultiDiskUnsupported => return .{ .kind = .zip_refused, .refused_reason = "multidisk", .size = size }, + error.OutOfMemory => return error.OutOfMemory, + }; + return switch (kind) { + .none => .{ .kind = .none, .size = 0 }, + .data => |n| .{ .kind = .data, .size = n }, + .zip => |zi| .{ .kind = .zip, .zip = zi, .size = size }, + }; +} + +const SignatureInfo = struct { offset: u32, size: u32 }; + +fn signatureOf(pe: *const pemod.Pe) ?SignatureInfo { + const sec = pe.dataDir(pemod.DATA_DIR_SECURITY); + if (sec.size == 0) return null; + return .{ .offset = sec.rva, .size = sec.size }; //security dir rva field IS a file offset +} + +//--------------------------------------------------------------------------- +//info + +fn cmd_info(path: []const u8, porcelain: bool) !u8 { + const data = try read_file(path); + const pe = pemod.Pe.parse(allocator, data) catch |err| { + errprintf("{s}: not a usable PE image: {}\n", .{ path, err }); + return 1; + }; + const sig = signatureOf(&pe); + const file_end = data.len; + const rep = try classifyOverlay(data, pe.overlay_offset, file_end); + const rdir = pe.dataDir(pemod.DATA_DIR_RESOURCE); + + if (porcelain) { + try print(HEADER); + try print("punkres_info_v1\n"); + try printf("file_size {d}\n", .{data.len}); + try printf("machine {d}\n", .{pe.machine}); + try printf("format {s}\n", .{if (pe.is_pe32plus) "pe32+" else "pe32"}); + try printf("nsections {d}\n", .{pe.num_sections}); + for (pe.sections) |*s| { + const nm = s.nameSlice(); + try printf("section {d} {d} {d} {d} {d} {d} ", .{ s.virtual_address, s.virtual_size, s.pointer_to_raw_data, s.size_of_raw_data, s.characteristics, nm.len }); + try printf("{s}\n", .{nm}); + } + try printf("resource_rva {d}\n", .{rdir.rva}); + try printf("resource_size {d}\n", .{rdir.size}); + try printf("overlay_offset {d}\n", .{pe.overlay_offset}); + try printf("overlay_size {d}\n", .{rep.size}); + const kind_str = switch (rep.kind) { + .none => "none", + .zip => "zip", + .data => "data", + .zip_refused => "zip-refused", + }; + try printf("overlay_kind {s}\n", .{kind_str}); + if (rep.kind == .zip_refused) { + try printf("zip_refused_reason {s}\n", .{rep.refused_reason}); + } + if (rep.zip) |zi| { + try printf("zip_style {s}\n", .{@tagName(zi.style)}); + try printf("zip_base {d}\n", .{zi.base}); + try printf("zip_members {d}\n", .{zi.member_count}); + try printf("zip_cd_offset_recorded {d}\n", .{zi.cd_offset_recorded}); + try printf("zip_comment_bytes {d}\n", .{zi.comment_len}); + try printf("zip_trailing_bytes {d}\n", .{zi.trailing_bytes}); + } + if (sig) |s| { + try printf("signature_offset {d}\n", .{s.offset}); + try printf("signature_size {d}\n", .{s.size}); + } else { + try print("signature_size 0\n"); + } + return 0; + } + + try print(HEADER); + try printf("\nfile: {s}\n", .{path}); + try printf("file_size: {d}\n", .{data.len}); + try printf("machine: {s} (0x{x:0>4})\n", .{ pemod.machineName(pe.machine), pe.machine }); + try printf("format: {s}\n", .{if (pe.is_pe32plus) "PE32+" else "PE32"}); + try print("\n# name vaddr vsize rawptr rawsize\n"); + for (pe.sections) |*s| { + try printf("S {s:<9} 0x{x:0>8} 0x{x:0>8} 0x{x:0>8} 0x{x:0>8}\n", .{ s.nameSlice(), s.virtual_address, s.virtual_size, s.pointer_to_raw_data, s.size_of_raw_data }); + } + if (rdir.rva != 0) { + const holder = pe.sectionContaining(rdir.rva); + try printf("\nresource_dir: rva 0x{x} size {d} (in {s})\n", .{ rdir.rva, rdir.size, if (holder) |h| h.nameSlice() else "?" }); + } else { + try print("\nresource_dir: none\n"); + } + try printf("overlay: offset {d} size {d}\n", .{ pe.overlay_offset, rep.size }); + switch (rep.kind) { + .none => try print("overlay_kind: none\n"), + .data => try print("overlay_kind: data (not a zip - offset semantics unknown to punkres)\n"), + .zip_refused => try printf("overlay_kind: zip punkres cannot process ({s})\n", .{rep.refused_reason}), + .zip => { + const zi = rep.zip.?; + try printf("overlay_kind: zip ({s}-relative offsets, base {d}, {d} members)\n", .{ @tagName(zi.style), zi.base, zi.member_count }); + if (zi.trailing_bytes != 0) { + try printf("overlay_note: {d} bytes trail the end-of-central-directory record\n", .{zi.trailing_bytes}); + } + }, + } + if (sig) |s| { + try printf("signature: present (offset {d} size {d}) - stamping invalidates it\n", .{ s.offset, s.size }); + } else { + try print("signature: none\n"); + } + return 0; +} + +//--------------------------------------------------------------------------- +//list + +fn cmd_list(path: []const u8, porcelain: bool) !u8 { + const data = try read_file(path); + const pe = pemod.Pe.parse(allocator, data) catch |err| { + errprintf("{s}: not a usable PE image: {}\n", .{ path, err }); + return 1; + }; + var tree = rsrc.parse(allocator, &pe) catch |err| switch (err) { + error.NoResourceDirectory => rsrc.Tree.empty(), + else => { + errprintf("{s}: malformed resource tree: {}\n", .{ path, err }); + return 1; + }, + }; + //render in directory order + std.mem.sort(rsrc.TypeEntry, tree.types.items, {}, typeLess); + for (tree.types.items) |*t| { + std.mem.sort(rsrc.NameEntry, t.names.items, {}, nameEntryLess); + for (t.names.items) |*ne| { + std.mem.sort(rsrc.LangEntry, ne.langs.items, {}, langLess); + } + } + try print(HEADER); + if (porcelain) { + try print("punkres_list_v1\n"); + try printf("resources {d}\n", .{tree.countItems()}); + for (tree.types.items) |*t| { + for (t.names.items) |*ne| { + for (ne.langs.items) |*le| { + const tt = try nameToken(t.name); + const nt = try nameToken(ne.name); + const lt = try nameToken(le.name); + try printf("resource {s} {s} {s} {d} {d}\n", .{ tt, nt, lt, le.item.data.len, le.item.codepage }); + } + } + } + return 0; + } + try printf("\nlisting: {s}\n", .{path}); + try printf("resources: {d}\n\n", .{tree.countItems()}); + try print("# type name lang size codepage\n"); + for (tree.types.items) |*t| { + for (t.names.items) |*ne| { + for (ne.langs.items) |*le| { + const tname = switch (t.name) { + .id => |id| blk: { + const known = typeName(id); + break :blk if (mem.eql(u8, known, "-")) + try std.fmt.allocPrint(allocator, "{d}", .{id}) + else + try std.fmt.allocPrint(allocator, "{d}:{s}", .{ id, known }); + }, + .name => try nameHuman(t.name), + }; + const nh = try nameHuman(ne.name); + const lh = try nameHuman(le.name); + try printf("R {s:<15} {s:<24} {s:>6} {d:>11} {d:>8}\n", .{ tname, nh, lh, le.item.data.len, le.item.codepage }); + } + } + } + return 0; +} + +fn typeLess(_: void, a: rsrc.TypeEntry, b: rsrc.TypeEntry) bool { + return rsrc.nameLessThan(a.name, b.name); +} +fn nameEntryLess(_: void, a: rsrc.NameEntry, b: rsrc.NameEntry) bool { + return rsrc.nameLessThan(a.name, b.name); +} +fn langLess(_: void, a: rsrc.LangEntry, b: rsrc.LangEntry) bool { + return rsrc.nameLessThan(a.name, b.name); +} + +//--------------------------------------------------------------------------- +//extract-ico + +const IconResolver = struct { + tree: *rsrc.Tree, + lang: rsrc.ResName, + + pub fn lookup(self: *const IconResolver, icon_id: u16) ?[]const u8 { + //prefer the group's language, fall back to any + if (self.findLang(icon_id, self.lang)) |d| return d; + return self.findAny(icon_id); + } + fn findLang(self: *const IconResolver, icon_id: u16, lang: rsrc.ResName) ?[]const u8 { + for (self.tree.types.items) |*t| { + if (!t.name.eqlIgnoreCase(.{ .id = rsrc.RT_ICON })) continue; + for (t.names.items) |*ne| { + if (!ne.name.eqlIgnoreCase(.{ .id = icon_id })) continue; + for (ne.langs.items) |*le| { + if (le.name.eqlIgnoreCase(lang)) return le.item.data; + } + } + } + return null; + } + fn findAny(self: *const IconResolver, icon_id: u16) ?[]const u8 { + for (self.tree.types.items) |*t| { + if (!t.name.eqlIgnoreCase(.{ .id = rsrc.RT_ICON })) continue; + for (t.names.items) |*ne| { + if (!ne.name.eqlIgnoreCase(.{ .id = icon_id })) continue; + if (ne.langs.items.len > 0) return ne.langs.items[0].item.data; + } + } + return null; + } +}; + +fn cmd_extract_ico(path: []const u8, out_path: []const u8, group_arg: ?[]const u8) !u8 { + const data = try read_file(path); + const pe = pemod.Pe.parse(allocator, data) catch |err| { + errprintf("{s}: not a usable PE image: {}\n", .{ path, err }); + return 1; + }; + var tree = rsrc.parse(allocator, &pe) catch |err| { + errprintf("{s}: cannot read resources: {}\n", .{ path, err }); + return 1; + }; + var group_name: rsrc.ResName = undefined; + var group_lang: rsrc.ResName = undefined; + if (group_arg) |ga| { + group_name = parseNameArg(ga) catch { + errprintf("bad -group argument\n", .{}); + return 1; + }; + //first lang under that name + var found = false; + for (tree.types.items) |*t| { + if (!t.name.eqlIgnoreCase(.{ .id = rsrc.RT_GROUP_ICON })) continue; + for (t.names.items) |*ne| { + if (!ne.name.eqlIgnoreCase(group_name)) continue; + var best: ?rsrc.ResName = null; + for (ne.langs.items) |*le| { + if (best == null or rsrc.nameLessThan(le.name, best.?)) best = le.name; + } + if (best) |b| { + group_lang = b; + found = true; + } + } + } + if (!found) { + errprintf("{s}: no RT_GROUP_ICON named {s}\n", .{ path, ga }); + return 1; + } + } else { + const first = tree.firstNameLang(.{ .id = rsrc.RT_GROUP_ICON }) orelse { + errprintf("{s}: no RT_GROUP_ICON resources\n", .{path}); + return 1; + }; + group_name = first.name; + group_lang = first.lang; + } + var group_data: ?[]const u8 = null; + for (tree.types.items) |*t| { + if (!t.name.eqlIgnoreCase(.{ .id = rsrc.RT_GROUP_ICON })) continue; + for (t.names.items) |*ne| { + if (!ne.name.eqlIgnoreCase(group_name)) continue; + for (ne.langs.items) |*le| { + if (le.name.eqlIgnoreCase(group_lang)) group_data = le.item.data; + } + } + } + const gd = group_data orelse { + errprintf("{s}: group icon data not found\n", .{path}); + return 1; + }; + const entries = ico.parseGroupData(allocator, gd) catch |err| { + errprintf("{s}: malformed RT_GROUP_ICON payload: {}\n", .{ path, err }); + return 1; + }; + const resolver = IconResolver{ .tree = &tree, .lang = group_lang }; + var mismatches: usize = 0; + const out = ico.buildIco(allocator, entries, &resolver, &mismatches) catch |err| switch (err) { + error.MissingIconResource => { + errprintf("{s}: group references an RT_ICON id that is absent\n", .{path}); + return 1; + }, + else => return err, + }; + try write_file(out_path, out); + try print(HEADER); + const gh = try nameHuman(group_name); + const lh = try nameHuman(group_lang); + try printf("\nextracted: {d} images (group {s} lang {s}) -> {s}\n", .{ entries.len, gh, lh, out_path }); + if (mismatches != 0) { + try printf("note: {d} group entries had a bytes_in_res differing from the stored RT_ICON size (actual sizes written)\n", .{mismatches}); + } + return 0; +} + +//--------------------------------------------------------------------------- +//set-icon + +const SetIconFlags = struct { + out_path: ?[]const u8 = null, + allow_file_relative_shift: bool = false, + allow_opaque_overlay: bool = false, + strip_signature: bool = false, +}; + +fn cmd_set_icon(pe_path: []const u8, ico_path: []const u8, flags: SetIconFlags) !u8 { + var data = try read_file(pe_path); + const pe = pemod.Pe.parse(allocator, data) catch |err| { + errprintf("{s}: not a usable PE image: {}\n", .{ pe_path, err }); + return 1; + }; + try print(HEADER); + try printf("\nstamping: {s}\n", .{pe_path}); + + //signature handling first: it caps the region the overlay analysis sees + var file_end: usize = data.len; + var stripped_signature = false; + if (signatureOf(&pe)) |sig| { + const soff: usize = sig.offset; + const send = soff + @as(usize, sig.size); + if (!flags.strip_signature) { + errprintf("REFUSED: {s} carries an Authenticode signature (certificate table offset {d} size {d}). Stamping rewrites the image and invalidates it.\n", .{ pe_path, sig.offset, sig.size }); + errprintf("Remedy: re-run with -strip-signature (and re-sign AFTER stamping - resource stamping always comes before signing).\n", .{}); + return 3; + } + //strippable only when the certificate table is the file tail (allowing + //its own trailing 8-byte alignment padding already inside size) + if (send != data.len or soff < pe.overlay_offset) { + errprintf("REFUSED: certificate table is not the trailing region of the file (offset {d} size {d} file_size {d}) - punkres cannot strip it safely.\n", .{ sig.offset, sig.size, data.len }); + return 2; + } + file_end = soff; + stripped_signature = true; + try printf("signature: stripped ({d} bytes; re-sign after stamping if needed)\n", .{sig.size}); + } + + //overlay classification and consent gates + const rep = try classifyOverlay(data, pe.overlay_offset, file_end); + switch (rep.kind) { + .zip_refused => { + errprintf("REFUSED: appended zip payload is {s} - punkres does not process zip64/multi-disk archives (no consent flag lifts this).\n", .{rep.refused_reason}); + return 2; + }, + .data => { + if (!flags.allow_opaque_overlay) { + errprintf("REFUSED: {d} bytes of non-zip overlay follow the PE image. punkres cannot know whether their internal offsets survive being moved.\n", .{rep.size}); + errprintf("Remedy: if the payload is safe to relocate verbatim (or offset-free), re-run with -allow-opaque-overlay.\n", .{}); + return 3; + } + }, + .zip => { + const zi = rep.zip.?; + if (zi.style == .file and !flags.allow_file_relative_shift) { + errprintf("REFUSED: appended zip payload records FILE-relative member offsets (base 0). Moving the overlay would break every recorded offset.\n", .{}); + errprintf("Remedy: rewrap the artifact archive-relative (punkshell bakes emit archive-relative kits), or re-run with -allow-file-relative-shift to relocate the overlay and rewrite each central-directory offset and the EOCD directory offset by the shift delta, PRESERVING the non-standard file-relative convention.\n", .{}); + return 3; + } + }, + .none => {}, + } + + //icon input + const ico_data = try read_file(ico_path); + const images = ico.parse(allocator, ico_data) catch |err| { + errprintf("{s}: not a usable .ico: {}\n", .{ ico_path, err }); + return 1; + }; + try printf("icon: {s} ({d} images)\n", .{ ico_path, images.len }); + + //existing resources -> edited tree (twapi-arm semantics: delete ALL icon + //and group entries, adopt the first pre-existing group's name+lang) + var tree = rsrc.parse(allocator, &pe) catch |err| switch (err) { + error.NoResourceDirectory => rsrc.Tree.empty(), + else => { + errprintf("{s}: malformed resource tree: {}\n", .{ pe_path, err }); + return 1; + }, + }; + var group_name: rsrc.ResName = .{ .id = 1 }; + var group_lang: rsrc.ResName = .{ .id = 1033 }; + if (tree.firstNameLang(.{ .id = rsrc.RT_GROUP_ICON })) |first| { + group_name = first.name; + group_lang = first.lang; + } + //inherit the codepage convention of the replaced entries (deterministic, + //input-derived; 0 for a stub with no prior icons) + const icon_cp = rsrc.firstItemCodepage(&tree, .{ .id = rsrc.RT_ICON }) orelse 0; + const group_cp = rsrc.firstItemCodepage(&tree, .{ .id = rsrc.RT_GROUP_ICON }) orelse 0; + _ = tree.removeType(.{ .id = rsrc.RT_ICON }); + _ = tree.removeType(.{ .id = rsrc.RT_GROUP_ICON }); + for (images, 0..) |*img, i| { + try rsrc.addItem(&tree, allocator, .{ .id = rsrc.RT_ICON }, .{ .id = @intCast(i + 1) }, group_lang, .{ .codepage = icon_cp, .data = img.data }); + } + const group_payload = try ico.buildGroupData(allocator, images, 1); + try rsrc.addItem(&tree, allocator, .{ .id = rsrc.RT_GROUP_ICON }, group_name, group_lang, .{ .codepage = group_cp, .data = group_payload }); + + //assemble + const plan = pemod.planStamp(&pe); + const rsrc_bytes = try rsrc.serialize(allocator, &tree, plan.new_rva); + const assembled = pemod.assemble(allocator, &pe, plan, rsrc_bytes) catch |err| switch (err) { + error.NoRoomForSectionHeader => { + errprintf("{s}: no room in the header region for another section header - cannot append a resource section.\n", .{pe_path}); + return 2; + }, + else => return err, + }; + const gh = try nameHuman(group_name); + const lh = try nameHuman(group_lang); + try printf("resources: {d} icons as ids 1..{d} + group {s} (lang {s}); other types preserved: {d} items\n", .{ images.len, images.len, gh, lh, tree.countItems() - images.len - 1 }); + switch (plan.placement) { + .append => try printf("mode: append section {s} (rva 0x{x})\n", .{ pemod.APPENDED_SECTION_NAME, plan.new_rva }), + .rebuild => |idx| try printf("mode: rebuild section {s} in place (rva 0x{x})\n", .{ pe.sections[idx].nameSlice(), plan.new_rva }), + } + + //overlay relocation + const delta: i64 = @as(i64, @intCast(assembled.new_overlay_offset)) - @as(i64, @intCast(pe.overlay_offset)); + const overlay_len = file_end - pe.overlay_offset; + const final = try allocator.alloc(u8, assembled.out.len + overlay_len); + @memcpy(final[0..assembled.out.len], assembled.out); + if (overlay_len > 0) { + const dst = final[assembled.out.len..]; + @memcpy(dst, data[pe.overlay_offset..file_end]); + if (rep.kind == .zip and rep.zip.?.style == .file) { + overlay.applyFileRelativeShift(dst, &rep.zip.?, pe.overlay_offset, delta) catch { + errprintf("REFUSED: shifted member offsets would not fit 32 bits.\n", .{}); + return 2; + }; + try printf("overlay: zip file-relative, {d} bytes moved by {d}; {d} central-directory offsets and the EOCD directory offset rewritten (non-standard convention PRESERVED)\n", .{ overlay_len, delta, rep.zip.?.member_count }); + } else if (rep.kind == .zip) { + try printf("overlay: zip archive-relative, {d} bytes preserved verbatim (moved by {d})\n", .{ overlay_len, delta }); + const zib = rep.zip.?; + if (zib.base != pe.overlay_offset) { + try printf("overlay_note: zip base sits {d} bytes after the overlay start (leading non-zip overlay bytes move with the payload)\n", .{ zib.base - pe.overlay_offset }); + } + } else { + try printf("overlay: {d} opaque bytes moved verbatim by {d} (consented)\n", .{ overlay_len, delta }); + } + } else { + try print("overlay: none\n"); + } + if (stripped_signature) { + //zero the security directory in the OUTPUT (table bytes were dropped) + const doff = pe.data_dir_offset + pemod.DATA_DIR_SECURITY * 8; + mem.writeInt(u32, final[doff..][0..4], 0, .little); + mem.writeInt(u32, final[doff + 4 ..][0..4], 0, .little); + } + pemod.finalizeChecksum(final, pe.checksum_offset); + + //read-back self-verification: parse what we are about to write + try verify_stamped(final, &tree, images, group_name, group_lang, rep, overlay_len, data[pe.overlay_offset..file_end], delta); + + const out_path = flags.out_path orelse pe_path; + if (flags.out_path) |op| { + try write_file(op, final); + } else { + try write_file_replacing(pe_path, final); + } + try printf("written: {s} ({d} bytes)\n", .{ out_path, final.len }); + return 0; +} + +const VerifyError = error{ + VerifyParse, + VerifyResources, + VerifyIconBytes, + VerifyOverlay, + OutOfMemory, +}; + +fn verify_stamped( + final: []const u8, + intended: *rsrc.Tree, + images: []const ico.Image, + group_name: rsrc.ResName, + group_lang: rsrc.ResName, + orig_rep: OverlayReport, + overlay_len: usize, + orig_overlay: []const u8, + delta: i64, +) !void { + const pe2 = pemod.Pe.parse(allocator, final) catch return error.VerifyParse; + var tree2 = rsrc.parse(allocator, &pe2) catch return error.VerifyParse; + //every intended item present with identical bytes, and counts match + if (tree2.countItems() != intended.countItems()) return error.VerifyResources; + for (intended.types.items) |*t| { + for (t.names.items) |*ne| { + for (ne.langs.items) |*le| { + const item = findItem(&tree2, t.name, ne.name, le.name) orelse return error.VerifyResources; + if (!mem.eql(u8, item.data, le.item.data)) return error.VerifyResources; + if (item.codepage != le.item.codepage) return error.VerifyResources; + } + } + } + //icons ids 1..N carry the ico's image bytes + for (images, 0..) |*img, i| { + const item = findItem(&tree2, .{ .id = rsrc.RT_ICON }, .{ .id = @intCast(i + 1) }, group_lang) orelse return error.VerifyIconBytes; + if (!mem.eql(u8, item.data, img.data)) return error.VerifyIconBytes; + } + if (findItem(&tree2, .{ .id = rsrc.RT_GROUP_ICON }, group_name, group_lang) == null) return error.VerifyIconBytes; + //overlay survives with its classification intact + const rep2 = try classifyOverlay(final, pe2.overlay_offset, final.len); + switch (orig_rep.kind) { + .none => { + if (overlay_len != 0 or rep2.kind != .none) return error.VerifyOverlay; + try print("verify: resources re-read OK; no overlay\n"); + }, + .data => { + if (rep2.kind != .data or rep2.size != overlay_len) return error.VerifyOverlay; + if (!mem.eql(u8, final[pe2.overlay_offset..], orig_overlay)) return error.VerifyOverlay; + try print("verify: resources re-read OK; opaque overlay byte-identical at new offset\n"); + }, + .zip => { + if (rep2.kind != .zip) return error.VerifyOverlay; + const zi = orig_rep.zip.?; + const zi2 = rep2.zip.?; + if (zi2.member_count != zi.member_count or zi2.style != zi.style) return error.VerifyOverlay; + if (zi.style == .archive) { + if (!mem.eql(u8, final[pe2.overlay_offset..], orig_overlay)) return error.VerifyOverlay; + //the derived base moves exactly with the overlay (it equals the + //overlay start only when nothing precedes the zip in the overlay) + const expected_base: usize = @intCast(@as(i64, @intCast(zi.base)) + delta); + if (zi2.base != expected_base) return error.VerifyOverlay; + try printf("verify: resources re-read OK; zip overlay byte-identical, archive-relative, {d} members\n", .{zi2.member_count}); + } else { + //file-relative: every member's local header must sit where the + //rewritten offsets now claim + for (zi2.recorded_offsets) |off| { + const p: usize = off; + if (p + 4 > final.len) return error.VerifyOverlay; + if (mem.readInt(u32, final[p..][0..4], .little) != overlay.LFH_SIG) return error.VerifyOverlay; + } + try printf("verify: resources re-read OK; zip overlay file-relative preserved, {d} members, all local headers at rewritten offsets\n", .{zi2.member_count}); + } + }, + .zip_refused => unreachable, + } +} + +fn findItem(tree: *rsrc.Tree, type_name: rsrc.ResName, name: rsrc.ResName, lang: rsrc.ResName) ?*rsrc.DataItem { + for (tree.types.items) |*t| { + if (!t.name.eqlIgnoreCase(type_name)) continue; + for (t.names.items) |*ne| { + if (!ne.name.eqlIgnoreCase(name)) continue; + for (ne.langs.items) |*le| { + if (le.name.eqlIgnoreCase(lang)) return &le.item; + } + } + } + return null; +} + +//--------------------------------------------------------------------------- + +pub fn main(init: std.process.Init) !u8 { + g_io = init.io; + g_stdout = stdout_file(); + + //materialize argv - the iterator's internal buffer owns the argument + //strings, so it is deliberately never deinitialized (program lifetime) + var args_it = std.process.Args.Iterator.initAllocator(init.minimal.args, allocator) catch |err| { + errprintf("Failed to parse command line: {}\n", .{err}); + std.process.exit(1); + }; + var argv_list = std.array_list.Managed([:0]const u8).init(allocator); + defer argv_list.deinit(); + while (args_it.next()) |a| { + try argv_list.append(a); + } + const argv = argv_list.items; + const argc = argv.len; + + if (argc >= 2 and mem.eql(u8, argv[1], "version")) { + try printf("punkres {s}\n", .{VERSION}); + return 0; + } else if ((argc == 3 or argc == 4) and mem.eql(u8, argv[1], "info")) { + if (argc == 4 and mem.eql(u8, argv[2], "-porcelain")) return cmd_info(argv[3], true); + if (argc == 3 and argv[2][0] != '-') return cmd_info(argv[2], false); + } else if ((argc == 3 or argc == 4) and mem.eql(u8, argv[1], "list")) { + if (argc == 4 and mem.eql(u8, argv[2], "-porcelain")) return cmd_list(argv[3], true); + if (argc == 3 and argv[2][0] != '-') return cmd_list(argv[2], false); + } else if (argc >= 4 and mem.eql(u8, argv[1], "extract-ico")) { + var group_arg: ?[]const u8 = null; + var i: usize = 2; + if (mem.eql(u8, argv[i], "-group") and argc >= 6) { + group_arg = argv[i + 1]; + i += 2; + } + if (i == argc - 2) { + return cmd_extract_ico(argv[i], argv[i + 1], group_arg); + } + } else if (argc >= 4 and mem.eql(u8, argv[1], "set-icon")) { + var flags = SetIconFlags{}; + var i: usize = 2; + var ok = true; + while (i < argc and argv[i][0] == '-') { + if (mem.eql(u8, argv[i], "-o") and i + 1 < argc) { + flags.out_path = argv[i + 1]; + i += 2; + } else if (mem.eql(u8, argv[i], "-allow-file-relative-shift")) { + flags.allow_file_relative_shift = true; + i += 1; + } else if (mem.eql(u8, argv[i], "-allow-opaque-overlay")) { + flags.allow_opaque_overlay = true; + i += 1; + } else if (mem.eql(u8, argv[i], "-strip-signature")) { + flags.strip_signature = true; + i += 1; + } else { + ok = false; + break; + } + } + if (ok and i == argc - 2) { + return cmd_set_icon(argv[i], argv[i + 1], flags); + } + } + + try print(HEADER); + try print_usage(if (argc > 0) argv[0] else "punkres"); + return 1; +} diff --git a/src/tools/punkres/src/punkres_test.zig b/src/tools/punkres/src/punkres_test.zig new file mode 100644 index 00000000..455a5261 --- /dev/null +++ b/src/tools/punkres/src/punkres_test.zig @@ -0,0 +1,476 @@ +//CLI characterization suite: drives the INSTALLED zig-out/bin/punkres binary +//against fixtures synthesized in a temp dir (src/synthpe.zig - no committed +//binaries). Pins the porcelain formats, the refusal exit codes, overlay +//preservation, idempotent re-stamping and the extract-ico roundtrip. + +const std = @import("std"); +const mainres = @import("punkres.zig"); +const synthpe = @import("synthpe.zig"); +const allocator = std.testing.allocator; +const builtin = @import("builtin"); +const debug = std.debug; +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; +const Io = std.Io; +const tio = std.testing.io; +const mem = std.mem; +const tmpDir = std.testing.tmpDir; +const TmpDir = std.testing.TmpDir; +const warn = std.debug.print; + +const EXENAME = if (builtin.os.tag == .windows) "punkres.exe" else "punkres"; +const BINARY_PATH = "./zig-out/bin/" ++ EXENAME; + +// Create a temp directory containing the executable being tested +// It's the caller responsibility to call `dir.cleanup()` +fn initTestDir() TmpDir { + const tmp = tmpDir(.{}); + Io.Dir.cwd().copyFile(BINARY_PATH, tmp.dir, EXENAME, tio, .{}) catch { + std.debug.print( + \\ + \\Unable to find/copy "{s}" to a temporary directory" + \\please make sure to run `zig build` before running `zig build test` + \\and that you run `zig build test` from the root directory of the project. + \\ + , + .{BINARY_PATH}, + ); + std.process.exit(1); + }; + return tmp; +} + +fn writeTmp(tmp: TmpDir, name: []const u8, bytes: []const u8) !void { + const f = try tmp.dir.createFile(tio, name, .{}); + defer f.close(tio); + try f.writePositionalAll(tio, bytes, 0); +} + +fn readTmp(alloc: mem.Allocator, tmp: TmpDir, name: []const u8) ![]u8 { + const f = try tmp.dir.openFile(tio, name, .{ .mode = .read_only }); + defer f.close(tio); + const size: usize = @intCast((try f.stat(tio)).size); + const buf = try alloc.alloc(u8, size); + const n = try f.readPositionalAll(tio, buf, 0); + return buf[0..n]; +} + +const RunResult = struct { + code: u32, + stdout: []u8, + stderr: []u8, + fn deinit(self: *RunResult, alloc: mem.Allocator) void { + alloc.free(self.stdout); + alloc.free(self.stderr); + } +}; + +fn printInvocation(args: []const []const u8) void { + warn("\n", .{}); + for (args) |arg| { + warn("{s} ", .{arg}); + } + warn("\n", .{}); +} + +fn runCapture(args: []const []const u8, tmp: TmpDir) !RunResult { + const max_output_size = 4 * 1024 * 1024; + const full_exe_path = if (builtin.os.tag == .windows) EXENAME else "./" ++ EXENAME; + + var process_args = std.array_list.Managed([]const u8).init(allocator); + defer process_args.deinit(); + process_args.append(full_exe_path) catch unreachable; + process_args.appendSlice(args) catch unreachable; + + const fscwd = Io.Dir.cwd(); + var cache_dir = fscwd.openDir(tio, ".zig-cache", .{}) catch + @panic("unable to open tmp dir for testing: unable to open .zig-cache dir"); + defer cache_dir.close(tio); + const parent_dir = cache_dir.openDir(tio, "tmp", .{}) catch + @panic("unable to make tmp dir for testing: unable to open .zig-cache/tmp dir"); + const dir = parent_dir.openDir(tio, &tmp.sub_path, .{}) catch + @panic("unable to make tmp dir for testing: unable to open the tmp dir"); + var buf = [_]u8{0} ** 300; + const tmpfolder_len = try dir.realPath(tio, &buf); + const tmpfolder = buf[0..tmpfolder_len]; + + //windows: pass the resolved path; a dir-handle cwd was historically unsupported there + const cwd_option: std.process.Child.Cwd = if (builtin.os.tag == .windows) + .{ .path = tmpfolder } + else + .{ .dir = tmp.dir }; + + var child = std.process.spawn(tio, .{ + .argv = process_args.items, + .cwd = cwd_option, + .stdout = .pipe, + .stderr = .pipe, + }) catch |err| debug.panic("Unable to spawn {s}: {s}\n", .{ full_exe_path, @errorName(err) }); + + var stdout_rbuf: [1024]u8 = undefined; + var stdout_reader = child.stdout.?.readerStreaming(tio, &stdout_rbuf); + const stdout = stdout_reader.interface.allocRemaining(allocator, .limited(max_output_size)) catch unreachable; + var stderr_rbuf: [1024]u8 = undefined; + var stderr_reader = child.stderr.?.readerStreaming(tio, &stderr_rbuf); + const stderr = stderr_reader.interface.allocRemaining(allocator, .limited(max_output_size)) catch unreachable; + + const term = child.wait(tio) catch |err| { + debug.panic("Unable to wait for {s}: {s}\n", .{ full_exe_path, @errorName(err) }); + }; + const code: u32 = switch (term) { + .exited => |c| c, + else => blk: { + warn("Process {s} terminated abnormally: {any}\n", .{ full_exe_path, term }); + printInvocation(process_args.items); + break :blk 0xffff; + }, + }; + return .{ .code = code, .stdout = stdout, .stderr = stderr }; +} + +fn expectExecute(args: []const []const u8, expect_code: u32, expect_output: []const u8, tmp: TmpDir) !void { + var res = try runCapture(args, tmp); + defer res.deinit(allocator); + if (res.code != expect_code) { + warn("exit code {d}, expected {d}\nstdout:\n{s}\nstderr:\n{s}\n", .{ res.code, expect_code, res.stdout, res.stderr }); + printInvocation(args); + return error.TestFailed; + } + if (!mem.eql(u8, expect_output, res.stdout)) { + printInvocation(args); + warn( + \\ + \\========= expected this output: ========= + \\{s} + \\=========== instead found this: ========= + \\{s} + \\========================================= + \\ + , .{ expect_output, res.stdout }); + return error.TestFailed; + } +} + +fn expectContains(haystack: []const u8, needles: []const []const u8) !void { + for (needles) |n| { + if (mem.indexOf(u8, haystack, n) == null) { + warn("MISSING needle: {s}\nin:\n{s}\n", .{ n, haystack }); + return error.TestFailed; + } + } +} + +fn expectExecuteContains(args: []const []const u8, expect_code: u32, stdout_needles: []const []const u8, stderr_needles: []const []const u8, tmp: TmpDir) !void { + var res = try runCapture(args, tmp); + defer res.deinit(allocator); + if (res.code != expect_code) { + warn("exit code {d}, expected {d}\nstdout:\n{s}\nstderr:\n{s}\n", .{ res.code, expect_code, res.stdout, res.stderr }); + printInvocation(args); + return error.TestFailed; + } + try expectContains(res.stdout, stdout_needles); + try expectContains(res.stderr, stderr_needles); +} + +//--------------------------------------------------------------------------- + +test "print usage" { + var tmp = initTestDir(); + defer tmp.cleanup(); + const args = [_][]const u8{"nonsense"}; + const output = mainres.HEADER ++ + \\ + \\Usage: + \\ + \\ punkres.exe info [-porcelain] + \\ punkres.exe list [-porcelain] + \\ punkres.exe extract-ico [-group ] + \\ punkres.exe set-icon [-o ] [-allow-file-relative-shift] + \\ [-allow-opaque-overlay] [-strip-signature] + \\ punkres.exe version + \\ + \\ set-icon replaces ALL RT_ICON/RT_GROUP_ICON resources with the images + \\ of (ids 1..N under the first pre-existing group's name and + \\ language; name 1 / lang 1033 when none), preserving every other + \\ resource type and any appended payload. Without -o the file is + \\ updated in place. Exit 3 = refused, a consent flag applies. + \\ + ; + try expectExecute(&args, 1, output, tmp); +} + +test "version" { + var tmp = initTestDir(); + defer tmp.cleanup(); + const args = [_][]const u8{"version"}; + try expectExecute(&args, 0, "punkres 0.1.0\n", tmp); +} + +test "stamp a plain synthetic PE: porcelain list pinned, idempotent, extract-ico roundtrip" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tmp = initTestDir(); + defer tmp.cleanup(); + + const img = try synthpe.buildPe(.{ .alloc = alloc }); + try writeTmp(tmp, "synth.exe", img); + const test_ico = try synthpe.buildTestIco(alloc, 3); + try writeTmp(tmp, "test.ico", test_ico); + + //stamp to a new file + const stamp_args = [_][]const u8{ "set-icon", "-o", "out1.exe", "synth.exe", "test.ico" }; + try expectExecuteContains(&stamp_args, 0, &[_][]const u8{ + "stamping: synth.exe", + "icon: test.ico (3 images)", + "resources: 3 icons as ids 1..3 + group 7 (lang 3081); other types preserved: 1 items", + "mode: append section .prsrc", + "overlay: none", + "verify: resources re-read OK; no overlay", + "written: out1.exe", + }, &[_][]const u8{}, tmp); + + //porcelain list of the stamped output - pins list format v1 + const list_args = [_][]const u8{ "list", "-porcelain", "out1.exe" }; + //codepages 1252 on the new icon entries pin the inherit-the-replaced- + //codepage behaviour (the synthetic stub's prior icons carry 1252) + const list_output = mainres.HEADER ++ + \\punkres_list_v1 + \\resources 5 + \\resource 3 1 3081 40 1252 + \\resource 3 2 3081 57 1252 + \\resource 3 3 3081 74 1252 + \\resource 14 7 3081 48 1252 + \\resource 16 1 1033 34 1252 + \\ + ; + try expectExecute(&list_args, 0, list_output, tmp); + + //re-stamping the stamped output with the same icon is byte-identical + const restamp_args = [_][]const u8{ "set-icon", "-o", "out2.exe", "out1.exe", "test.ico" }; + try expectExecuteContains(&restamp_args, 0, &[_][]const u8{ + "mode: rebuild section .prsrc", + "verify: resources re-read OK; no overlay", + }, &[_][]const u8{}, tmp); + const out1 = try readTmp(alloc, tmp, "out1.exe"); + const out2 = try readTmp(alloc, tmp, "out2.exe"); + try expect(mem.eql(u8, out1, out2)); + + //extract-ico reassembles the exact input .ico + const ex_args = [_][]const u8{ "extract-ico", "out1.exe", "ex.ico" }; + try expectExecuteContains(&ex_args, 0, &[_][]const u8{ + "extracted: 3 images (group 7 lang 3081) -> ex.ico", + }, &[_][]const u8{}, tmp); + const ex = try readTmp(alloc, tmp, "ex.ico"); + try expect(mem.eql(u8, test_ico, ex)); + + //in-place stamping (no -o) produces the same bytes as out1 + const inplace_args = [_][]const u8{ "set-icon", "synth.exe", "test.ico" }; + try expectExecuteContains(&inplace_args, 0, &[_][]const u8{ + "written: synth.exe", + }, &[_][]const u8{}, tmp); + const synth_after = try readTmp(alloc, tmp, "synth.exe"); + try expect(mem.eql(u8, out1, synth_after)); +} + +test "archive-relative zip overlay is preserved verbatim" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tmp = initTestDir(); + defer tmp.cleanup(); + + const img = try synthpe.buildPe(.{ .alloc = alloc }); + const entries = [_]synthpe.ZipEntry{ + .{ .name = "payload/boot.tcl", .data = "puts {hello from the payload}" }, + .{ .name = "payload/data.bin", .data = "\x01\x02\x03\x04\x05\x06\x07\x08\x09" }, + }; + const zip_bytes = try synthpe.buildStoredZip(alloc, &entries, .archive, 0, "kitzip"); + const kit = try alloc.alloc(u8, img.len + zip_bytes.len); + @memcpy(kit[0..img.len], img); + @memcpy(kit[img.len..], zip_bytes); + try writeTmp(tmp, "kit.exe", kit); + const test_ico = try synthpe.buildTestIco(alloc, 2); + try writeTmp(tmp, "test.ico", test_ico); + + const stamp_args = [_][]const u8{ "set-icon", "-o", "kitout.exe", "kit.exe", "test.ico" }; + try expectExecuteContains(&stamp_args, 0, &[_][]const u8{ + "overlay: zip archive-relative,", + "bytes preserved verbatim", + "verify: resources re-read OK; zip overlay byte-identical, archive-relative, 2 members", + }, &[_][]const u8{}, tmp); + + //the overlay bytes at the tail are byte-identical to the appended zip + const out = try readTmp(alloc, tmp, "kitout.exe"); + try expect(out.len > zip_bytes.len); + try expect(mem.eql(u8, out[out.len - zip_bytes.len ..], zip_bytes)); + + //info of the stamped kit re-reads the payload + const info_args = [_][]const u8{ "info", "-porcelain", "kitout.exe" }; + try expectExecuteContains(&info_args, 0, &[_][]const u8{ + "punkres_info_v1\n", + "format pe32+\n", + "nsections 4\n", + "overlay_kind zip\n", + "zip_style archive\n", + "zip_members 2\n", + "signature_size 0\n", + }, &[_][]const u8{}, tmp); +} + +test "file-relative zip overlay: refused by default, consent flag rewrites offsets preserving the convention" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tmp = initTestDir(); + defer tmp.cleanup(); + + const img = try synthpe.buildPe(.{ .alloc = alloc }); + const entries = [_]synthpe.ZipEntry{ + .{ .name = "a.txt", .data = "member a" }, + .{ .name = "b.txt", .data = "member b, longer payload" }, + .{ .name = "c/d.txt", .data = "nested member" }, + }; + const zip_bytes = try synthpe.buildStoredZip(alloc, &entries, .file, @intCast(img.len), ""); + const kit = try alloc.alloc(u8, img.len + zip_bytes.len); + @memcpy(kit[0..img.len], img); + @memcpy(kit[img.len..], zip_bytes); + try writeTmp(tmp, "frkit.exe", kit); + const test_ico = try synthpe.buildTestIco(alloc, 1); + try writeTmp(tmp, "test.ico", test_ico); + + //refused by default, exit 3, reason and remedy on stderr + const refuse_args = [_][]const u8{ "set-icon", "-o", "frout.exe", "frkit.exe", "test.ico" }; + try expectExecuteContains(&refuse_args, 3, &[_][]const u8{ + "stamping: frkit.exe", + }, &[_][]const u8{ + "REFUSED", + "FILE-relative member offsets", + "-allow-file-relative-shift", + }, tmp); + + //with consent: stamped, offsets rewritten, convention preserved + const consent_args = [_][]const u8{ "set-icon", "-o", "frout.exe", "-allow-file-relative-shift", "frkit.exe", "test.ico" }; + try expectExecuteContains(&consent_args, 0, &[_][]const u8{ + "overlay: zip file-relative,", + "central-directory offsets and the EOCD directory offset rewritten", + "verify: resources re-read OK; zip overlay file-relative preserved, 3 members, all local headers at rewritten offsets", + }, &[_][]const u8{}, tmp); + + const info_args = [_][]const u8{ "info", "-porcelain", "frout.exe" }; + try expectExecuteContains(&info_args, 0, &[_][]const u8{ + "overlay_kind zip\n", + "zip_style file\n", + "zip_base 0\n", + "zip_members 3\n", + }, &[_][]const u8{}, tmp); + + //member DATA is untouched by the fix-up: local headers + payload bytes are + //identical, only the central-directory offset fields and EOCD moved + const out = try readTmp(alloc, tmp, "frout.exe"); + //locate the shifted overlay: it is at out.len - zip_bytes.len + const moved = out[out.len - zip_bytes.len ..]; + //local-file region (headers+data for 3 stored members) precedes the CD; + //compute its length from the archive-relative structure: first CD entry + //offset in the ORIGINAL equals img.len + local region start (file style) + //-> local region length = recorded_cd_offset - img.len + var cd_rel: usize = 0; + { + //parse EOCD of original zip_bytes (no comment): last 22 bytes + const eocd = zip_bytes[zip_bytes.len - 22 ..]; + const cd_off_recorded = mem.readInt(u32, eocd[16..20], .little); + cd_rel = cd_off_recorded - img.len; + } + try expect(mem.eql(u8, moved[0..cd_rel], zip_bytes[0..cd_rel])); +} + +test "opaque non-zip overlay: refused by default, moved verbatim with consent" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tmp = initTestDir(); + defer tmp.cleanup(); + + const img = try synthpe.buildPe(.{ .alloc = alloc }); + const blob = "OPAQUE-PAYLOAD-................-with-no-zip-structure-at-all"; + const kit = try alloc.alloc(u8, img.len + blob.len); + @memcpy(kit[0..img.len], img); + @memcpy(kit[img.len..], blob); + try writeTmp(tmp, "blobkit.exe", kit); + const test_ico = try synthpe.buildTestIco(alloc, 1); + try writeTmp(tmp, "test.ico", test_ico); + + const refuse_args = [_][]const u8{ "set-icon", "-o", "bout.exe", "blobkit.exe", "test.ico" }; + try expectExecuteContains(&refuse_args, 3, &[_][]const u8{}, &[_][]const u8{ + "REFUSED", + "non-zip overlay", + "-allow-opaque-overlay", + }, tmp); + + const consent_args = [_][]const u8{ "set-icon", "-o", "bout.exe", "-allow-opaque-overlay", "blobkit.exe", "test.ico" }; + try expectExecuteContains(&consent_args, 0, &[_][]const u8{ + "opaque bytes moved verbatim", + "verify: resources re-read OK; opaque overlay byte-identical at new offset", + }, &[_][]const u8{}, tmp); + const out = try readTmp(alloc, tmp, "bout.exe"); + try expect(mem.eql(u8, out[out.len - blob.len ..], blob)); +} + +test "archive-relative zip preceded by other overlay bytes (nested-payload shape) stamps and verifies" { + //the punkshell tclsfe stub carries its own zip payload; concatenating a + //further zip (the zipcat shape built from a raw stub) yields an overlay + //whose zip base sits AFTER the overlay start - everything before the zip + //moves with it and the derived base moves by exactly the shift delta + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tmp = initTestDir(); + defer tmp.cleanup(); + + const img = try synthpe.buildPe(.{ .alloc = alloc }); + const lead = "LEADING-NON-ZIP-OVERLAY-BYTES-(a-prior-payload)"; + const entries = [_]synthpe.ZipEntry{ + .{ .name = "x.txt", .data = "xxxx" }, + .{ .name = "y.txt", .data = "yyyyyyyy" }, + }; + const zip_bytes = try synthpe.buildStoredZip(alloc, &entries, .archive, 0, ""); + const kit = try alloc.alloc(u8, img.len + lead.len + zip_bytes.len); + @memcpy(kit[0..img.len], img); + @memcpy(kit[img.len..][0..lead.len], lead); + @memcpy(kit[img.len + lead.len ..], zip_bytes); + try writeTmp(tmp, "nested.exe", kit); + const test_ico = try synthpe.buildTestIco(alloc, 2); + try writeTmp(tmp, "test.ico", test_ico); + + const stamp_args = [_][]const u8{ "set-icon", "-o", "nout.exe", "nested.exe", "test.ico" }; + try expectExecuteContains(&stamp_args, 0, &[_][]const u8{ + "overlay: zip archive-relative,", + "overlay_note: zip base sits 47 bytes after the overlay start", + "verify: resources re-read OK; zip overlay byte-identical, archive-relative, 2 members", + }, &[_][]const u8{}, tmp); + //whole overlay (lead + zip) is verbatim at the tail + const out = try readTmp(alloc, tmp, "nout.exe"); + const tail = out[out.len - lead.len - zip_bytes.len ..]; + try expect(mem.eql(u8, tail[0..lead.len], lead)); + try expect(mem.eql(u8, tail[lead.len..], zip_bytes)); +} + +test "info porcelain on an unstamped synthetic PE" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tmp = initTestDir(); + defer tmp.cleanup(); + const img = try synthpe.buildPe(.{ .alloc = alloc }); + try writeTmp(tmp, "synth.exe", img); + const args = [_][]const u8{ "info", "-porcelain", "synth.exe" }; + try expectExecuteContains(&args, 0, &[_][]const u8{ + "punkres_info_v1\n", + "machine 34404\n", + "format pe32+\n", + "nsections 3\n", + "overlay_kind none\n", + "overlay_size 0\n", + "signature_size 0\n", + }, &[_][]const u8{}, tmp); +} diff --git a/src/tools/punkres/src/rsrc.zig b/src/tools/punkres/src/rsrc.zig new file mode 100644 index 00000000..5751d99d --- /dev/null +++ b/src/tools/punkres/src/rsrc.zig @@ -0,0 +1,445 @@ +//punkres resource directory tree: parse the full IMAGE_RESOURCE_DIRECTORY tree +//of an existing PE into a generic in-memory model, edit it (replace icon +//types), and serialize it back deterministically. The model is deliberately +//GENERIC over types/names/languages so later stamping work (RT_VERSION - see +//punkshell goal G-128 notes) extends this module rather than starting another. +// +//Memory: parse/serialize allocate through the supplied allocator and never +//free; callers use an arena (tests) or a program-lifetime allocator (CLI). +//Data slices in a parsed tree alias the input buffer. + +const std = @import("std"); +const mem = std.mem; +const pemod = @import("./pe.zig"); + +pub const RT_ICON: u32 = 3; +pub const RT_GROUP_ICON: u32 = 14; +pub const RT_VERSION: u32 = 16; +pub const RT_MANIFEST: u32 = 24; + +pub const ParseError = error{ + NoResourceDirectory, + MalformedResourceTree, + OutOfMemory, +}; + +pub const ResName = union(enum) { + id: u32, + name: []const u16, //UTF-16 code units as stored, no terminator + + pub fn eqlIgnoreCase(a: ResName, b: ResName) bool { + switch (a) { + .id => |ai| return switch (b) { + .id => |bi| ai == bi, + .name => false, + }, + .name => |an| switch (b) { + .id => return false, + .name => |bn| { + if (an.len != bn.len) return false; + for (an, bn) |x, y| { + if (upper16(x) != upper16(y)) return false; + } + return true; + }, + }, + } + } +}; + +fn upper16(c: u16) u16 { + if (c >= 'a' and c <= 'z') return c - ('a' - 'A'); + return c; +} + +//directory sort order mandated by the format: named entries first (the loader +//binary-searches), case-insensitively ordered; then id entries ascending +pub fn nameLessThan(a: ResName, b: ResName) bool { + switch (a) { + .name => |an| switch (b) { + .name => |bn| { + const n = @min(an.len, bn.len); + var i: usize = 0; + while (i < n) : (i += 1) { + const x = upper16(an[i]); + const y = upper16(bn[i]); + if (x != y) return x < y; + } + return an.len < bn.len; + }, + .id => return true, //names sort before ids + }, + .id => |ai| switch (b) { + .name => return false, + .id => |bi| return ai < bi, + }, + } +} + +pub const DataItem = struct { + codepage: u32, + data: []const u8, +}; + +pub const LangEntry = struct { + name: ResName, //practically an id (language), kept generic + item: DataItem, +}; + +pub const NameEntry = struct { + name: ResName, + langs: std.ArrayList(LangEntry), +}; + +pub const TypeEntry = struct { + name: ResName, + names: std.ArrayList(NameEntry), +}; + +pub const Tree = struct { + types: std.ArrayList(TypeEntry), + + pub fn empty() Tree { + return .{ .types = .empty }; + } + + pub fn findType(self: *Tree, name: ResName) ?*TypeEntry { + for (self.types.items) |*t| { + if (t.name.eqlIgnoreCase(name)) return t; + } + return null; + } + + pub fn removeType(self: *Tree, name: ResName) bool { + for (self.types.items, 0..) |*t, i| { + if (t.name.eqlIgnoreCase(name)) { + _ = self.types.orderedRemove(i); + return true; + } + } + return false; + } + + pub fn addType(self: *Tree, alloc: mem.Allocator, name: ResName) !*TypeEntry { + try self.types.append(alloc, .{ .name = name, .names = .empty }); + return &self.types.items[self.types.items.len - 1]; + } + + pub fn countItems(self: *const Tree) usize { + var n: usize = 0; + for (self.types.items) |*t| { + for (t.names.items) |*ne| n += ne.langs.items.len; + } + return n; + } + + //first entry of a type in directory order (named entries sorted first, + //then ids ascending) - matches Windows EnumResourceNames enumeration and + //therefore the twapi arm's "first pre-existing group" selection + pub fn firstNameLang(self: *const Tree, type_name: ResName) ?struct { name: ResName, lang: ResName } { + var best_name: ?*const NameEntry = null; + for (self.types.items) |*t| { + if (!t.name.eqlIgnoreCase(type_name)) continue; + for (t.names.items) |*ne| { + if (best_name == null or nameLessThan(ne.name, best_name.?.name)) best_name = ne; + } + } + const ne = best_name orelse return null; + var best_lang: ?ResName = null; + for (ne.langs.items) |*le| { + if (best_lang == null or nameLessThan(le.name, best_lang.?)) best_lang = le.name; + } + return .{ .name = ne.name, .lang = best_lang.? }; + } +}; + +//codepage of a type's first item in directory order - used by set-icon to +//inherit the codepage convention of the entries it replaces (twapi's arm +//writes the build host's ANSI codepage there; punkres stays deterministic by +//deriving from the input instead). Null when the type has no items. +pub fn firstItemCodepage(tree: *const Tree, type_name: ResName) ?u32 { + const first = tree.firstNameLang(type_name) orelse return null; + for (tree.types.items) |*t| { + if (!t.name.eqlIgnoreCase(type_name)) continue; + for (t.names.items) |*ne| { + if (!ne.name.eqlIgnoreCase(first.name)) continue; + for (ne.langs.items) |*le| { + if (le.name.eqlIgnoreCase(first.lang)) return le.item.codepage; + } + } + } + return null; +} + +pub fn addItem(tree: *Tree, alloc: mem.Allocator, type_name: ResName, name: ResName, lang: ResName, item: DataItem) !void { + const t = tree.findType(type_name) orelse try tree.addType(alloc, type_name); + var ne: ?*NameEntry = null; + for (t.names.items) |*cand| { + if (cand.name.eqlIgnoreCase(name)) { + ne = cand; + break; + } + } + if (ne == null) { + try t.names.append(alloc, .{ .name = name, .langs = .empty }); + ne = &t.names.items[t.names.items.len - 1]; + } + for (ne.?.langs.items) |*le| { + if (le.name.eqlIgnoreCase(lang)) { + le.item = item; + return; + } + } + try ne.?.langs.append(alloc, .{ .name = lang, .item = item }); +} + +//--------------------------------------------------------------------------- +//parsing + +const Cursor = struct { + pe: *const pemod.Pe, + root_off: usize, //file offset of the resource directory root + dir_size_hint: usize, + + fn rel(self: *const Cursor, offset: u32) ParseError!usize { + const off = self.root_off + (offset & 0x7fffffff); + if (off >= self.pe.data.len) return error.MalformedResourceTree; + return off; + } +}; + +pub fn parse(alloc: mem.Allocator, pe: *const pemod.Pe) ParseError!Tree { + const rdir = pe.dataDir(pemod.DATA_DIR_RESOURCE); + if (rdir.rva == 0) return error.NoResourceDirectory; + const root_off = pe.rvaToOffset(rdir.rva) orelse return error.MalformedResourceTree; + const cur = Cursor{ .pe = pe, .root_off = root_off, .dir_size_hint = rdir.size }; + var tree = Tree.empty(); + //level 1: types + var it1 = try DirIter.init(&cur, 0); + while (try it1.next(&cur)) |e1| { + const type_name = try readName(alloc, &cur, e1.name_field); + if (!e1.is_subdir) return error.MalformedResourceTree; + var it2 = try DirIter.init(&cur, e1.child_field & 0x7fffffff); + while (try it2.next(&cur)) |e2| { + const name_name = try readName(alloc, &cur, e2.name_field); + if (!e2.is_subdir) return error.MalformedResourceTree; + var it3 = try DirIter.init(&cur, e2.child_field & 0x7fffffff); + while (try it3.next(&cur)) |e3| { + const lang_name = try readName(alloc, &cur, e3.name_field); + if (e3.is_subdir) return error.MalformedResourceTree; //depth > 3 + const de_off = try cur.rel(e3.child_field); + if (de_off + 16 > pe.data.len) return error.MalformedResourceTree; + const data_rva = mem.readInt(u32, pe.data[de_off..][0..4], .little); + const data_size = mem.readInt(u32, pe.data[de_off + 4 ..][0..4], .little); + const codepage = mem.readInt(u32, pe.data[de_off + 8 ..][0..4], .little); + const data_off = pe.rvaToOffset(data_rva) orelse return error.MalformedResourceTree; + if (data_off + data_size > pe.data.len) return error.MalformedResourceTree; + const item = DataItem{ .codepage = codepage, .data = pe.data[data_off..][0..data_size] }; + addItem(&tree, alloc, type_name, name_name, lang_name, item) catch return error.OutOfMemory; + } + } + } + return tree; +} + +const RawEntry = struct { + name_field: u32, + child_field: u32, + is_subdir: bool, +}; + +const DirIter = struct { + entry_off: usize, + remaining: usize, + + fn init(cur: *const Cursor, dir_offset: u32) ParseError!DirIter { + const off = try cur.rel(dir_offset); + if (off + 16 > cur.pe.data.len) return error.MalformedResourceTree; + const n_named = mem.readInt(u16, cur.pe.data[off + 12 ..][0..2], .little); + const n_id = mem.readInt(u16, cur.pe.data[off + 14 ..][0..2], .little); + const total = @as(usize, n_named) + n_id; + if (off + 16 + total * 8 > cur.pe.data.len) return error.MalformedResourceTree; + if (total > 0x10000) return error.MalformedResourceTree; + return .{ .entry_off = off + 16, .remaining = total }; + } + + fn next(self: *DirIter, cur: *const Cursor) ParseError!?RawEntry { + if (self.remaining == 0) return null; + self.remaining -= 1; + const off = self.entry_off; + self.entry_off += 8; + const name_field = mem.readInt(u32, cur.pe.data[off..][0..4], .little); + const child_field = mem.readInt(u32, cur.pe.data[off + 4 ..][0..4], .little); + return .{ + .name_field = name_field, + .child_field = child_field, + .is_subdir = (child_field & 0x80000000) != 0, + }; + } +}; + +fn readName(alloc: mem.Allocator, cur: *const Cursor, name_field: u32) ParseError!ResName { + if (name_field & 0x80000000 == 0) return .{ .id = name_field }; + const off = try cur.rel(name_field); + if (off + 2 > cur.pe.data.len) return error.MalformedResourceTree; + const count = mem.readInt(u16, cur.pe.data[off..][0..2], .little); + if (off + 2 + @as(usize, count) * 2 > cur.pe.data.len) return error.MalformedResourceTree; + const units = alloc.alloc(u16, count) catch return error.OutOfMemory; + var i: usize = 0; + while (i < count) : (i += 1) { + units[i] = mem.readInt(u16, cur.pe.data[off + 2 + i * 2 ..][0..2], .little); + } + return .{ .name = units }; +} + +//--------------------------------------------------------------------------- +//serialization +// +//Layout: directory tables (root, then level-2, then level-3, in sorted order), +//then IMAGE_RESOURCE_DATA_ENTRY descriptors, then name strings, then data +//blobs 8-byte aligned. All offsets are root-relative per the format; data +//descriptor RVAs are section_rva + in-section offset. Directory timestamps +//are zero for determinism (stamping the same input twice is byte-identical). + +pub const SerializeError = error{OutOfMemory}; + +const SortCtx = struct { + fn typeLess(_: void, a: TypeEntry, b: TypeEntry) bool { + return nameLessThan(a.name, b.name); + } + fn nameLess(_: void, a: NameEntry, b: NameEntry) bool { + return nameLessThan(a.name, b.name); + } + fn langLess(_: void, a: LangEntry, b: LangEntry) bool { + return nameLessThan(a.name, b.name); + } +}; + +pub fn serialize(alloc: mem.Allocator, tree: *Tree, section_rva: u32) SerializeError![]u8 { + //sort every level (loader requirement: named first, ascending) + std.mem.sort(TypeEntry, tree.types.items, {}, SortCtx.typeLess); + for (tree.types.items) |*t| { + std.mem.sort(NameEntry, t.names.items, {}, SortCtx.nameLess); + for (t.names.items) |*ne| { + std.mem.sort(LangEntry, ne.langs.items, {}, SortCtx.langLess); + } + } + + //layout accounting + var n_l2_dirs: usize = 0; //one per type + var n_l3_dirs: usize = 0; //one per name + var n_items: usize = 0; + var strings_bytes: usize = 0; + var data_bytes: usize = 0; + for (tree.types.items) |*t| { + n_l2_dirs += 1; + if (t.name == .name) strings_bytes += 2 + t.name.name.len * 2; + for (t.names.items) |*ne| { + n_l3_dirs += 1; + if (ne.name == .name) strings_bytes += 2 + ne.name.name.len * 2; + for (ne.langs.items) |*le| { + n_items += 1; + if (le.name == .name) strings_bytes += 2 + le.name.name.len * 2; + data_bytes += pemod.alignUp(le.item.data.len, 8); + } + } + } + const root_size: usize = 16 + tree.types.items.len * 8; + var l2_total: usize = 0; + for (tree.types.items) |*t| l2_total += 16 + t.names.items.len * 8; + var l3_total: usize = 0; + for (tree.types.items) |*t| { + for (t.names.items) |*ne| l3_total += 16 + ne.langs.items.len * 8; + } + const desc_off = root_size + l2_total + l3_total; + const strings_off = desc_off + n_items * 16; + const data_off = pemod.alignUp(strings_off + strings_bytes, 8); + const total = data_off + data_bytes; + + const out = try alloc.alloc(u8, total); + @memset(out, 0); + + //walk again, emitting. Offsets of the dir tables are computed as we go: + //l2 dirs sit consecutively after root in type order; l3 dirs sit + //consecutively after all l2 dirs in (type, name) order. + var l2_off = root_size; + var l3_off = root_size + l2_total; + var cur_desc = desc_off; + var cur_string = strings_off; + var cur_data = data_off; + + writeDirHeader(out, 0, tree.types.items); + var root_entry: usize = 16; + for (tree.types.items) |*t| { + writeEntry(out, root_entry, t.name, @intCast(l2_off | 0x80000000), &cur_string); + root_entry += 8; + writeDirHeaderNames(out, l2_off, t.names.items); + var l2_entry = l2_off + 16; + l2_off += 16 + t.names.items.len * 8; + for (t.names.items) |*ne| { + writeEntry(out, l2_entry, ne.name, @intCast(l3_off | 0x80000000), &cur_string); + l2_entry += 8; + writeDirHeaderLangs(out, l3_off, ne.langs.items); + var l3_entry = l3_off + 16; + l3_off += 16 + ne.langs.items.len * 8; + for (ne.langs.items) |*le| { + writeEntry(out, l3_entry, le.name, @intCast(cur_desc), &cur_string); + l3_entry += 8; + //data entry descriptor + mem.writeInt(u32, out[cur_desc..][0..4], section_rva + @as(u32, @intCast(cur_data)), .little); + mem.writeInt(u32, out[cur_desc + 4 ..][0..4], @intCast(le.item.data.len), .little); + mem.writeInt(u32, out[cur_desc + 8 ..][0..4], le.item.codepage, .little); + cur_desc += 16; + @memcpy(out[cur_data..][0..le.item.data.len], le.item.data); + cur_data += pemod.alignUp(le.item.data.len, 8); + } + } + } + return out; +} + +fn countSplit(comptime T: type, items: []const T) struct { named: u16, ids: u16 } { + var named: u16 = 0; + var ids: u16 = 0; + for (items) |*e| { + switch (e.name) { + .name => named += 1, + .id => ids += 1, + } + } + return .{ .named = named, .ids = ids }; +} + +fn writeDirHeaderAt(out: []u8, off: usize, named: u16, ids: u16) void { + //Characteristics, TimeDateStamp, Major, Minor stay zero (determinism) + mem.writeInt(u16, out[off + 12 ..][0..2], named, .little); + mem.writeInt(u16, out[off + 14 ..][0..2], ids, .little); +} +fn writeDirHeader(out: []u8, off: usize, items: []const TypeEntry) void { + const c = countSplit(TypeEntry, items); + writeDirHeaderAt(out, off, c.named, c.ids); +} +fn writeDirHeaderNames(out: []u8, off: usize, items: []const NameEntry) void { + const c = countSplit(NameEntry, items); + writeDirHeaderAt(out, off, c.named, c.ids); +} +fn writeDirHeaderLangs(out: []u8, off: usize, items: []const LangEntry) void { + const c = countSplit(LangEntry, items); + writeDirHeaderAt(out, off, c.named, c.ids); +} + +fn writeEntry(out: []u8, entry_off: usize, name: ResName, child_field: u32, cur_string: *usize) void { + switch (name) { + .id => |id| mem.writeInt(u32, out[entry_off..][0..4], id, .little), + .name => |units| { + const soff = cur_string.*; + mem.writeInt(u16, out[soff..][0..2], @intCast(units.len), .little); + for (units, 0..) |u, i| { + mem.writeInt(u16, out[soff + 2 + i * 2 ..][0..2], u, .little); + } + cur_string.* += 2 + units.len * 2; + mem.writeInt(u32, out[entry_off..][0..4], @as(u32, @intCast(soff)) | 0x80000000, .little); + }, + } + mem.writeInt(u32, out[entry_off + 4 ..][0..4], child_field, .little); +} diff --git a/src/tools/punkres/src/rsrc_test.zig b/src/tools/punkres/src/rsrc_test.zig new file mode 100644 index 00000000..5293ff22 --- /dev/null +++ b/src/tools/punkres/src/rsrc_test.zig @@ -0,0 +1,156 @@ +const std = @import("std"); +const mem = std.mem; +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; + +const pemod = @import("./pe.zig"); +const rsrc = @import("./rsrc.zig"); +const synthpe = @import("./synthpe.zig"); + +fn utf16(comptime s: []const u8) [s.len]u16 { + var out: [s.len]u16 = undefined; + for (s, 0..) |c, i| out[i] = c; + return out; +} + +test "parse the synthetic default tree" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const img = try synthpe.buildPe(.{ .alloc = alloc }); + const pe = try pemod.Pe.parse(alloc, img); + var tree = try rsrc.parse(alloc, &pe); + try expectEqual(@as(usize, 3), tree.countItems()); + //RT_ICON id 5 lang 3081 + const icon = tree.findType(.{ .id = rsrc.RT_ICON }).?; + try expectEqual(@as(usize, 1), icon.names.items.len); + try expect(icon.names.items[0].name.eqlIgnoreCase(.{ .id = 5 })); + try expect(icon.names.items[0].langs.items[0].name.eqlIgnoreCase(.{ .id = 3081 })); + try expect(mem.eql(u8, icon.names.items[0].langs.items[0].item.data, &[_]u8{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee })); + //RT_VERSION data + codepage + const ver = tree.findType(.{ .id = rsrc.RT_VERSION }).?; + try expectEqual(@as(u32, 1252), ver.names.items[0].langs.items[0].item.codepage); + try expect(mem.eql(u8, ver.names.items[0].langs.items[0].item.data, "SYNTHETIC-VERSION-RESOURCE-PAYLOAD")); +} + +test "serialize(parse(x)) == x for our own serializer output" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const img = try synthpe.buildPe(.{ .alloc = alloc }); + const pe = try pemod.Pe.parse(alloc, img); + const rdir = pe.dataDir(pemod.DATA_DIR_RESOURCE); + const raw_off = pe.rvaToOffset(rdir.rva).?; + const original = img[raw_off..][0..rdir.size]; + var tree = try rsrc.parse(alloc, &pe); + const rebuilt = try rsrc.serialize(alloc, &tree, rdir.rva); + try expect(mem.eql(u8, original, rebuilt)); +} + +test "directory ordering: named entries first (case-insensitive), then ids ascending" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tree = rsrc.Tree.empty(); + const zebra = utf16("zebra"); + const alpha = utf16("ALPHA"); + //insert deliberately out of order, and as names under one type + try rsrc.addItem(&tree, alloc, .{ .id = 10 }, .{ .id = 9 }, .{ .id = 0 }, .{ .codepage = 0, .data = "nine" }); + try rsrc.addItem(&tree, alloc, .{ .id = 10 }, .{ .name = &zebra }, .{ .id = 0 }, .{ .codepage = 0, .data = "zzz" }); + try rsrc.addItem(&tree, alloc, .{ .id = 10 }, .{ .id = 2 }, .{ .id = 0 }, .{ .codepage = 0, .data = "two" }); + try rsrc.addItem(&tree, alloc, .{ .id = 10 }, .{ .name = &alpha }, .{ .id = 0 }, .{ .codepage = 0, .data = "aaa" }); + const bytes = try rsrc.serialize(alloc, &tree, 0x5000); + //rebuild a fake single-section PE around the serialized bytes to reparse + const img = try buildAround(alloc, bytes); + const pe = try pemod.Pe.parse(alloc, img); + var parsed = try rsrc.parse(alloc, &pe); + const t = parsed.findType(.{ .id = 10 }).?; + try expectEqual(@as(usize, 4), t.names.items.len); + //parse order == directory order: ALPHA, zebra, 2, 9 + try expect(t.names.items[0].name.eqlIgnoreCase(.{ .name = &alpha })); + try expect(t.names.items[1].name.eqlIgnoreCase(.{ .name = &zebra })); + try expect(t.names.items[2].name.eqlIgnoreCase(.{ .id = 2 })); + try expect(t.names.items[3].name.eqlIgnoreCase(.{ .id = 9 })); + try expect(mem.eql(u8, t.names.items[0].langs.items[0].item.data, "aaa")); +} + +//minimal wrapper: synthesize a PE whose .rsrc bytes are exactly `bytes` at +//rva 0x5000 - reuse synthpe by injecting a tree is not possible for raw bytes, +//so build a tiny custom image here +fn buildAround(alloc: mem.Allocator, bytes: []const u8) ![]u8 { + //build a synth PE with default tree, then splice: simplest correct path is + //to construct via synthpe with a custom tree that reproduces `bytes`... + //instead parse is exercised through a handmade single-section image: + const e_lfanew: u32 = 0x80; + const opt_size: u16 = 240; + const size_of_headers: u32 = 0x400; + const rsrc_rva: u32 = 0x5000; + const raw_size: u32 = @intCast(pemod.alignUp(bytes.len, 0x200)); + const total = size_of_headers + raw_size; + const out = try alloc.alloc(u8, total); + @memset(out, 0); + out[0] = 'M'; + out[1] = 'Z'; + mem.writeInt(u32, out[0x3c..][0..4], e_lfanew, .little); + @memcpy(out[e_lfanew..][0..4], "PE\x00\x00"); + const coff = e_lfanew + 4; + mem.writeInt(u16, out[coff..][0..2], 0x8664, .little); + mem.writeInt(u16, out[coff + 2 ..][0..2], 1, .little); + mem.writeInt(u16, out[coff + 16 ..][0..2], opt_size, .little); + const opt = coff + 20; + mem.writeInt(u16, out[opt..][0..2], 0x20b, .little); + mem.writeInt(u32, out[opt + 32 ..][0..4], 0x1000, .little); //SectionAlignment + mem.writeInt(u32, out[opt + 36 ..][0..4], 0x200, .little); //FileAlignment + mem.writeInt(u32, out[opt + 56 ..][0..4], rsrc_rva + 0x1000, .little); //SizeOfImage + mem.writeInt(u32, out[opt + 60 ..][0..4], size_of_headers, .little); + mem.writeInt(u32, out[opt + 108 ..][0..4], 16, .little); + const ddir = opt + 112; + mem.writeInt(u32, out[ddir + pemod.DATA_DIR_RESOURCE * 8 ..][0..4], rsrc_rva, .little); + mem.writeInt(u32, out[ddir + pemod.DATA_DIR_RESOURCE * 8 + 4 ..][0..4], @intCast(bytes.len), .little); + const soff = opt + opt_size; + @memcpy(out[soff..][0..5], ".rsrc"); + mem.writeInt(u32, out[soff + 8 ..][0..4], @intCast(bytes.len), .little); + mem.writeInt(u32, out[soff + 12 ..][0..4], rsrc_rva, .little); + mem.writeInt(u32, out[soff + 16 ..][0..4], raw_size, .little); + mem.writeInt(u32, out[soff + 20 ..][0..4], size_of_headers, .little); + mem.writeInt(u32, out[soff + 36 ..][0..4], 0x40000040, .little); + @memcpy(out[size_of_headers..][0..bytes.len], bytes); + return out; +} + +test "firstNameLang picks named groups before id groups, lowest lang" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tree = rsrc.Tree.empty(); + try rsrc.addItem(&tree, alloc, .{ .id = rsrc.RT_GROUP_ICON }, .{ .id = 3 }, .{ .id = 1033 }, .{ .codepage = 0, .data = "g3" }); + const sfe = utf16("SFE"); + try rsrc.addItem(&tree, alloc, .{ .id = rsrc.RT_GROUP_ICON }, .{ .name = &sfe }, .{ .id = 2057 }, .{ .codepage = 0, .data = "gs" }); + try rsrc.addItem(&tree, alloc, .{ .id = rsrc.RT_GROUP_ICON }, .{ .name = &sfe }, .{ .id = 1033 }, .{ .codepage = 0, .data = "gs2" }); + const first = tree.firstNameLang(.{ .id = rsrc.RT_GROUP_ICON }).?; + try expect(first.name.eqlIgnoreCase(.{ .name = &sfe })); + try expect(first.lang.eqlIgnoreCase(.{ .id = 1033 })); + //with only id groups, lowest id wins + var tree2 = rsrc.Tree.empty(); + try rsrc.addItem(&tree2, alloc, .{ .id = rsrc.RT_GROUP_ICON }, .{ .id = 9 }, .{ .id = 1042 }, .{ .codepage = 0, .data = "a" }); + try rsrc.addItem(&tree2, alloc, .{ .id = rsrc.RT_GROUP_ICON }, .{ .id = 4 }, .{ .id = 1055 }, .{ .codepage = 0, .data = "b" }); + const f2 = tree2.firstNameLang(.{ .id = rsrc.RT_GROUP_ICON }).?; + try expect(f2.name.eqlIgnoreCase(.{ .id = 4 })); + try expect(f2.lang.eqlIgnoreCase(.{ .id = 1055 })); +} + +test "removeType and addItem replacement semantics" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var tree = try synthpe.defaultTree(alloc); + try expectEqual(@as(usize, 3), tree.countItems()); + try expect(tree.removeType(.{ .id = rsrc.RT_ICON })); + try expect(!tree.removeType(.{ .id = rsrc.RT_ICON })); + try expectEqual(@as(usize, 2), tree.countItems()); + //same (type,name,lang) replaces rather than duplicates + try rsrc.addItem(&tree, alloc, .{ .id = rsrc.RT_VERSION }, .{ .id = 1 }, .{ .id = 1033 }, .{ .codepage = 0, .data = "NEW" }); + try expectEqual(@as(usize, 2), tree.countItems()); + const ver = tree.findType(.{ .id = rsrc.RT_VERSION }).?; + try expect(mem.eql(u8, ver.names.items[0].langs.items[0].item.data, "NEW")); +} diff --git a/src/tools/punkres/src/synthpe.zig b/src/tools/punkres/src/synthpe.zig new file mode 100644 index 00000000..89ca4417 --- /dev/null +++ b/src/tools/punkres/src/synthpe.zig @@ -0,0 +1,281 @@ +//punkres test fixtures: synthesize a minimal-but-valid PE32+ image (with the +//punkshell runtime section shape - .rsrc followed by .reloc, so it is NOT +//last), a stored-method zip with either offset convention, and a synthetic +//multi-image .ico. No committed binary fixtures: every test input is built in +//memory by this module. + +const std = @import("std"); +const mem = std.mem; +const rsrc = @import("./rsrc.zig"); +const pemod = @import("./pe.zig"); + +pub const FILE_ALIGN: u32 = 0x200; +pub const SECT_ALIGN: u32 = 0x1000; + +pub const SynthOptions = struct { + //when true the image carries a .rsrc section (built from `tree`, or a + //default tree with RT_VERSION + one RT_GROUP_ICON id 7 lang 3081 + one + //RT_ICON id 5) between .text and .reloc + with_rsrc: bool = true, + tree: ?*rsrc.Tree = null, + alloc: mem.Allocator, +}; + +pub fn defaultTree(alloc: mem.Allocator) !rsrc.Tree { + var tree = rsrc.Tree.empty(); + //an existing single-image icon set under group id 7 lang 3081 (en-AU) so + //stamping tests can verify group name+lang adoption and old-icon removal + //codepage 1252 mirrors the MSVC-linked runtime lineage, and exercises + //set-icon's inherit-the-replaced-codepage behaviour in the CLI pins + try rsrc.addItem(&tree, alloc, .{ .id = rsrc.RT_ICON }, .{ .id = 5 }, .{ .id = 3081 }, .{ + .codepage = 1252, + .data = &[_]u8{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee }, + }); + var grp = try alloc.alloc(u8, 6 + 14); + mem.writeInt(u16, grp[0..2], 0, .little); + mem.writeInt(u16, grp[2..4], 1, .little); + mem.writeInt(u16, grp[4..6], 1, .little); + grp[6] = 16; //width + grp[7] = 16; //height + grp[8] = 0; + grp[9] = 0; + mem.writeInt(u16, grp[10..12], 1, .little); //planes + mem.writeInt(u16, grp[12..14], 32, .little); //bits + mem.writeInt(u32, grp[14..18], 5, .little); //bytes_in_res + mem.writeInt(u16, grp[18..20], 5, .little); //icon id 5 + try rsrc.addItem(&tree, alloc, .{ .id = rsrc.RT_GROUP_ICON }, .{ .id = 7 }, .{ .id = 3081 }, .{ + .codepage = 1252, + .data = grp, + }); + //a version resource that must SURVIVE stamping untouched + try rsrc.addItem(&tree, alloc, .{ .id = rsrc.RT_VERSION }, .{ .id = 1 }, .{ .id = 1033 }, .{ + .codepage = 1252, + .data = "SYNTHETIC-VERSION-RESOURCE-PAYLOAD", + }); + return tree; +} + +pub fn buildPe(opts: SynthOptions) ![]u8 { + const alloc = opts.alloc; + const e_lfanew: u32 = 0x80; + const n_sections: u16 = if (opts.with_rsrc) 3 else 2; + const opt_size: u16 = 240; //PE32+ with 16 data directories + const headers_end = e_lfanew + 4 + 20 + @as(u32, opt_size) + @as(u32, n_sections) * 40; + std.debug.assert(headers_end <= 0x400); + const size_of_headers: u32 = 0x400; + + //section raw payloads + const text_raw = try alloc.alloc(u8, 0x200); + @memset(text_raw, 0xcc); + var rsrc_raw: []u8 = &[_]u8{}; + const text_rva: u32 = 0x1000; + var rva: u32 = text_rva + SECT_ALIGN; + var rsrc_rva: u32 = 0; + if (opts.with_rsrc) { + rsrc_rva = rva; + var default_tree: rsrc.Tree = undefined; + var tree_ptr: *rsrc.Tree = undefined; + if (opts.tree) |t| { + tree_ptr = t; + } else { + default_tree = try defaultTree(alloc); + tree_ptr = &default_tree; + } + rsrc_raw = try rsrc.serialize(alloc, tree_ptr, rsrc_rva); + rva += @intCast(pemod.alignUp(rsrc_raw.len, SECT_ALIGN)); + } + const reloc_rva = rva; + const reloc_raw = try alloc.alloc(u8, 12); + @memset(reloc_raw, 0); + mem.writeInt(u32, reloc_raw[0..4], text_rva, .little); //page rva + mem.writeInt(u32, reloc_raw[4..8], 12, .little); //block size + const size_of_image = reloc_rva + SECT_ALIGN; + + //file layout + const text_ptr: u32 = size_of_headers; + const rsrc_ptr: u32 = text_ptr + 0x200; + const rsrc_raw_size: u32 = @intCast(pemod.alignUp(rsrc_raw.len, FILE_ALIGN)); + const reloc_ptr: u32 = if (opts.with_rsrc) rsrc_ptr + rsrc_raw_size else rsrc_ptr; + const reloc_raw_size: u32 = FILE_ALIGN; + const total = reloc_ptr + reloc_raw_size; + + const out = try alloc.alloc(u8, total); + @memset(out, 0); + //DOS header + out[0] = 'M'; + out[1] = 'Z'; + mem.writeInt(u32, out[0x3c..][0..4], e_lfanew, .little); + //PE signature + COFF header + @memcpy(out[e_lfanew..][0..4], "PE\x00\x00"); + const coff = e_lfanew + 4; + mem.writeInt(u16, out[coff..][0..2], 0x8664, .little); //machine + mem.writeInt(u16, out[coff + 2 ..][0..2], n_sections, .little); + mem.writeInt(u32, out[coff + 4 ..][0..4], 0x66600000, .little); //timestamp (fixed) + mem.writeInt(u16, out[coff + 16 ..][0..2], opt_size, .little); + mem.writeInt(u16, out[coff + 18 ..][0..2], 0x0022, .little); //characteristics + //optional header PE32+ + const opt = coff + 20; + mem.writeInt(u16, out[opt..][0..2], 0x20b, .little); + out[opt + 2] = 14; //linker versions (cosmetic) + mem.writeInt(u32, out[opt + 4 ..][0..4], 0x200, .little); //SizeOfCode + mem.writeInt(u32, out[opt + 8 ..][0..4], rsrc_raw_size + reloc_raw_size, .little); //SizeOfInitializedData + mem.writeInt(u32, out[opt + 16 ..][0..4], text_rva, .little); //AddressOfEntryPoint + mem.writeInt(u32, out[opt + 20 ..][0..4], text_rva, .little); //BaseOfCode + mem.writeInt(u64, out[opt + 24 ..][0..8], 0x140000000, .little); //ImageBase + mem.writeInt(u32, out[opt + 32 ..][0..4], SECT_ALIGN, .little); + mem.writeInt(u32, out[opt + 36 ..][0..4], FILE_ALIGN, .little); + mem.writeInt(u16, out[opt + 40 ..][0..2], 6, .little); //OS major + mem.writeInt(u16, out[opt + 48 ..][0..2], 6, .little); //subsystem major + mem.writeInt(u32, out[opt + 56 ..][0..4], size_of_image, .little); + mem.writeInt(u32, out[opt + 60 ..][0..4], size_of_headers, .little); + //checksum at opt+64 left zero + mem.writeInt(u16, out[opt + 68 ..][0..2], 3, .little); //subsystem console + mem.writeInt(u64, out[opt + 72 ..][0..8], 0x100000, .little); //stack reserve + mem.writeInt(u64, out[opt + 80 ..][0..8], 0x1000, .little); + mem.writeInt(u64, out[opt + 88 ..][0..8], 0x100000, .little); //heap reserve + mem.writeInt(u64, out[opt + 96 ..][0..8], 0x1000, .little); + mem.writeInt(u32, out[opt + 108 ..][0..4], 16, .little); //NumberOfRvaAndSizes + const ddir = opt + 112; + if (opts.with_rsrc) { + mem.writeInt(u32, out[ddir + pemod.DATA_DIR_RESOURCE * 8 ..][0..4], rsrc_rva, .little); + mem.writeInt(u32, out[ddir + pemod.DATA_DIR_RESOURCE * 8 + 4 ..][0..4], @intCast(rsrc_raw.len), .little); + } + mem.writeInt(u32, out[ddir + pemod.DATA_DIR_BASERELOC * 8 ..][0..4], reloc_rva, .little); + mem.writeInt(u32, out[ddir + pemod.DATA_DIR_BASERELOC * 8 + 4 ..][0..4], 12, .little); + //section table + var soff = opt + opt_size; + writeSection(out, soff, ".text", 0x200, text_rva, 0x200, text_ptr, 0x60000020); + soff += 40; + if (opts.with_rsrc) { + writeSection(out, soff, ".rsrc", @intCast(rsrc_raw.len), rsrc_rva, rsrc_raw_size, rsrc_ptr, 0x40000040); + soff += 40; + } + writeSection(out, soff, ".reloc", 12, reloc_rva, reloc_raw_size, reloc_ptr, 0x42000040); + //raw data + @memcpy(out[text_ptr..][0..0x200], text_raw); + if (opts.with_rsrc) { + @memcpy(out[rsrc_ptr..][0..rsrc_raw.len], rsrc_raw); + } + @memcpy(out[reloc_ptr..][0..12], reloc_raw); + //self-consistent checksum like a real linked image + pemod.finalizeChecksum(out, opt + 64); + return out; +} + +fn writeSection(out: []u8, off: usize, name: []const u8, vsize: u32, va: u32, rawsize: u32, rawptr: u32, chars: u32) void { + @memcpy(out[off..][0..name.len], name); + mem.writeInt(u32, out[off + 8 ..][0..4], vsize, .little); + mem.writeInt(u32, out[off + 12 ..][0..4], va, .little); + mem.writeInt(u32, out[off + 16 ..][0..4], rawsize, .little); + mem.writeInt(u32, out[off + 20 ..][0..4], rawptr, .little); + mem.writeInt(u32, out[off + 36 ..][0..4], chars, .little); +} + +pub const ZipEntry = struct { name: []const u8, data: []const u8 }; +pub const ZipStyleOpt = enum { archive, file }; + +//Build a stored-method zip. style archive: member offsets relative to the zip +//start. style file: offsets are base + relative (the file-relative convention +//of a zip appended at absolute position `base`). Optional comment. +pub fn buildStoredZip(alloc: mem.Allocator, entries: []const ZipEntry, style: ZipStyleOpt, base: u32, comment: []const u8) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + var local_offsets = try alloc.alloc(u32, entries.len); + for (entries, 0..) |e, i| { + local_offsets[i] = @intCast(buf.items.len); + const crc = std.hash.Crc32.hash(e.data); + try appendU32(&buf, alloc, 0x04034b50); + try appendU16(&buf, alloc, 20); //version needed + try appendU16(&buf, alloc, 0); //flags + try appendU16(&buf, alloc, 0); //method store + try appendU16(&buf, alloc, 0x6020); //mod time + try appendU16(&buf, alloc, 0x5a21); //mod date + try appendU32(&buf, alloc, crc); + try appendU32(&buf, alloc, @intCast(e.data.len)); + try appendU32(&buf, alloc, @intCast(e.data.len)); + try appendU16(&buf, alloc, @intCast(e.name.len)); + try appendU16(&buf, alloc, 0); //extra len + try buf.appendSlice(alloc, e.name); + try buf.appendSlice(alloc, e.data); + } + const cd_start: u32 = @intCast(buf.items.len); + for (entries, 0..) |e, i| { + const crc = std.hash.Crc32.hash(e.data); + try appendU32(&buf, alloc, 0x02014b50); + try appendU16(&buf, alloc, 20); //version made by + try appendU16(&buf, alloc, 20); //version needed + try appendU16(&buf, alloc, 0); //flags + try appendU16(&buf, alloc, 0); //method + try appendU16(&buf, alloc, 0x6020); + try appendU16(&buf, alloc, 0x5a21); + try appendU32(&buf, alloc, crc); + try appendU32(&buf, alloc, @intCast(e.data.len)); + try appendU32(&buf, alloc, @intCast(e.data.len)); + try appendU16(&buf, alloc, @intCast(e.name.len)); + try appendU16(&buf, alloc, 0); //extra + try appendU16(&buf, alloc, 0); //comment + try appendU16(&buf, alloc, 0); //disk + try appendU16(&buf, alloc, 0); //internal attrs + try appendU32(&buf, alloc, 0); //external attrs + const rec: u32 = if (style == .file) base + local_offsets[i] else local_offsets[i]; + try appendU32(&buf, alloc, rec); + try buf.appendSlice(alloc, e.name); + } + const cd_size: u32 = @intCast(buf.items.len - cd_start); + try appendU32(&buf, alloc, 0x06054b50); + try appendU16(&buf, alloc, 0); //disk + try appendU16(&buf, alloc, 0); //cd disk + try appendU16(&buf, alloc, @intCast(entries.len)); + try appendU16(&buf, alloc, @intCast(entries.len)); + try appendU32(&buf, alloc, cd_size); + const rec_cd: u32 = if (style == .file) base + cd_start else cd_start; + try appendU32(&buf, alloc, rec_cd); + try appendU16(&buf, alloc, @intCast(comment.len)); + try buf.appendSlice(alloc, comment); + return buf.items; +} + +fn appendU16(buf: *std.ArrayList(u8), alloc: mem.Allocator, v: u16) !void { + var tmp: [2]u8 = undefined; + mem.writeInt(u16, &tmp, v, .little); + try buf.appendSlice(alloc, &tmp); +} +fn appendU32(buf: *std.ArrayList(u8), alloc: mem.Allocator, v: u32) !void { + var tmp: [4]u8 = undefined; + mem.writeInt(u32, &tmp, v, .little); + try buf.appendSlice(alloc, &tmp); +} + +//Synthetic .ico: n images with deterministic filler payloads of distinct sizes +pub fn buildTestIco(alloc: mem.Allocator, n: u16) ![]u8 { + var total: usize = 6 + @as(usize, n) * 16; + var sizes = try alloc.alloc(usize, n); + var i: usize = 0; + while (i < n) : (i += 1) { + sizes[i] = 40 + i * 17; + total += sizes[i]; + } + const out = try alloc.alloc(u8, total); + mem.writeInt(u16, out[0..2], 0, .little); + mem.writeInt(u16, out[2..4], 1, .little); + mem.writeInt(u16, out[4..6], n, .little); + var data_off: usize = 6 + @as(usize, n) * 16; + i = 0; + while (i < n) : (i += 1) { + const off = 6 + i * 16; + const dims = [_]u8{ 16, 32, 48, 0 }; //0 = 256 + out[off] = dims[i % dims.len]; + out[off + 1] = dims[i % dims.len]; + out[off + 2] = 0; + out[off + 3] = 0; + mem.writeInt(u16, out[off + 4 ..][0..2], 1, .little); + mem.writeInt(u16, out[off + 6 ..][0..2], 32, .little); + mem.writeInt(u32, out[off + 8 ..][0..4], @intCast(sizes[i]), .little); + mem.writeInt(u32, out[off + 12 ..][0..4], @intCast(data_off), .little); + var j: usize = 0; + while (j < sizes[i]) : (j += 1) { + out[data_off + j] = @intCast((i * 31 + j * 7) & 0xff); + } + data_off += sizes[i]; + } + return out; +}