Browse Source

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
master
Julian Noble 4 days ago
parent
commit
19d1b8b30a
  1. 10
      src/tools/AGENTS.md
  2. 24
      src/tools/punkres/LICENSE
  3. 71
      src/tools/punkres/PROVENANCE.md
  4. 85
      src/tools/punkres/README.md
  5. 63
      src/tools/punkres/build.zig
  6. 9
      src/tools/punkres/build.zig.zon
  7. 158
      src/tools/punkres/src/ico.zig
  8. 78
      src/tools/punkres/src/ico_test.zig
  9. 181
      src/tools/punkres/src/overlay.zig
  10. 148
      src/tools/punkres/src/overlay_test.zig
  11. 342
      src/tools/punkres/src/pe.zig
  12. 126
      src/tools/punkres/src/pe_test.zig
  13. 857
      src/tools/punkres/src/punkres.zig
  14. 476
      src/tools/punkres/src/punkres_test.zig
  15. 445
      src/tools/punkres/src/rsrc.zig
  16. 156
      src/tools/punkres/src/rsrc_test.zig
  17. 281
      src/tools/punkres/src/synthpe.zig

10
src/tools/AGENTS.md

@ -3,7 +3,7 @@
## Purpose ## Purpose
First-party build tools vendored as source (zig), built on demand into `bin/` by the 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 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 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 forks vendored in full, with no fetch step, so a clean checkout carries everything
@ -33,10 +33,14 @@ needed to build them.
## Verification ## Verification
- Per tree: `zig build test` from the tree root with the pinned toolchain must pass - 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 (punkzip: 130/130 as vendored; punkres: 31/31 as vendored). Gate on the exit code,
suite's negative tests print EOCD warnings while passing. 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 ## Child DOX Index
- `punkzip/` — vendored punkzip zip read/write tool (G-126 accelerator; no child - `punkzip/` — vendored punkzip zip read/write tool (G-126 accelerator; no child
AGENTS.md - `punkzip/PROVENANCE.md` carries the tree's rules) 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)

24
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.

71
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 = .{}`.

85
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] <pefile>
punkres list [-porcelain] <pefile>
punkres extract-ico [-group <id|name>] <pefile> <out.ico>
punkres set-icon [-o <outfile>] [-allow-file-relative-shift]
[-allow-opaque-overlay] [-strip-signature] <pefile> <icon.ico>
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.

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

9
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 = .{},
}

158
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;
}

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

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

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

342
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",
};
}

126
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"));
}

857
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] <pefile>
\\ {s} list [-porcelain] <pefile>
\\ {s} extract-ico [-group <id|name>] <pefile> <out.ico>
\\ {s} set-icon [-o <outfile>] [-allow-file-relative-shift]
\\ [-allow-opaque-overlay] [-strip-signature] <pefile> <icon.ico>
\\ {s} version
\\
\\ set-icon replaces ALL RT_ICON/RT_GROUP_ICON resources with the images
\\ of <icon.ico> (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:<percent-encoded-utf8>
//(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;
}

476
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] <pefile>
\\ punkres.exe list [-porcelain] <pefile>
\\ punkres.exe extract-ico [-group <id|name>] <pefile> <out.ico>
\\ punkres.exe set-icon [-o <outfile>] [-allow-file-relative-shift]
\\ [-allow-opaque-overlay] [-strip-signature] <pefile> <icon.ico>
\\ punkres.exe version
\\
\\ set-icon replaces ALL RT_ICON/RT_GROUP_ICON resources with the images
\\ of <icon.ico> (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);
}

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

156
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"));
}

281
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;
}
Loading…
Cancel
Save