Browse Source
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.commaster
17 changed files with 3507 additions and 3 deletions
@ -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. |
||||||
@ -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 = .{}`. |
||||||
@ -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. |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 = .{}, |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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)); |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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", |
||||||
|
}; |
||||||
|
} |
||||||
@ -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")); |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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")); |
||||||
|
} |
||||||
@ -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…
Reference in new issue