commit ebed95fc91a2d6f1e042400b283edfe8d19e4875
parent 1123bccf0f06705889b6919ade846e01af6e7cb4
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Sun, 20 Sep 2026 16:02:27 -0300
exe: read the icons a Windows binary carries
An icon inside an executable is an ico taken apart: a directory in one
resource and its images in others. Putting it back together is the only way
to get at artwork that ships with no file beside it.
Diffstat:
7 files changed, 491 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -4,3 +4,7 @@
*.exe
*.dll
*.test
+
+# Binaries kept on purpose: the resource sections a Windows toolchain
+# lays out, which the exe package is read against.
+!exe/testdata/*.dll
diff --git a/exe/exe.go b/exe/exe.go
@@ -0,0 +1,72 @@
+// Package exe reads the icons a Windows executable or DLL carries.
+//
+// A portable executable keeps its icons in the resource section as two
+// resource types that refer to each other: RT_GROUP_ICON holds a directory of
+// the sizes one icon is drawn at, and RT_ICON holds the image for each of
+// them. A directory and the images it names are an ico file in all but the
+// offsets, so this package puts them back together and hands out ico files
+// the ico package reads.
+//
+// A binary may carry several groups. Explorer draws the first by ordinal,
+// which is the one Icons returns first.
+package exe
+
+import (
+ "errors"
+ "fmt"
+)
+
+// Errors returned by the reader. They are wrapped with detail, so compare
+// with errors.Is.
+var (
+ // ErrNoIcons means the binary carries no icon resources.
+ ErrNoIcons = errors.New("no icons found")
+ // ErrMalformed means a resource offset or length disagrees with the
+ // section holding it.
+ ErrMalformed = errors.New("malformed resource section")
+)
+
+// Resource types, as the resource directory numbers them.
+const (
+ typeIcon = 3
+ typeIconGroup = 14
+)
+
+const (
+ // directoryHeaderSize is the fixed part of a resource directory, before
+ // its entries.
+ directoryHeaderSize = 16
+ // directoryEntrySize is one entry in a resource directory.
+ directoryEntrySize = 8
+ // dataEntrySize is the leaf that points at the bytes of a resource.
+ dataEntrySize = 16
+ // groupHeaderSize is the fixed part of a group icon directory.
+ groupHeaderSize = 6
+ // groupEntrySize is one icon's row in a group icon directory. It differs
+ // from the ico row only in naming a resource rather than an offset.
+ groupEntrySize = 14
+ // icoEntrySize is one icon's row in an ico directory.
+ icoEntrySize = 16
+)
+
+// Group is one icon a binary carries, at every size it holds.
+type Group struct {
+ // ID is the ordinal the resource directory gives the group. Explorer
+ // draws the lowest.
+ ID uint16
+ // Sizes are the dimensions the directory lists, largest first.
+ Sizes []int
+
+ ico []byte
+}
+
+func (g Group) String() string {
+ return fmt.Sprintf("icon %d (%d sizes)", g.ID, len(g.Sizes))
+}
+
+// ICO returns the group as an ico file, which ico.Decode reads.
+//
+// The bytes are not copied, and must not be modified.
+func (g Group) ICO() []byte {
+ return g.ico
+}
diff --git a/exe/exe_test.go b/exe/exe_test.go
@@ -0,0 +1,169 @@
+package exe
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "image/color"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// The fixtures are built by mingw's windres and linker from an ico this
+// module wrote, so the test runs against a resource section a Windows
+// toolchain laid out rather than one written here.
+//
+// x86_64-w64-mingw32-windres icon.rc icon.o
+// x86_64-w64-mingw32-gcc -shared -nostdlib -o icon.dll stub.c icon.o
+const (
+ withIcons = "testdata/icon.dll"
+ withoutIcons = "testdata/plain.dll"
+ original = "testdata/icon.ico"
+)
+
+func open(t *testing.T, path string) *os.File {
+ t.Helper()
+ f, err := os.Open(path)
+ if err != nil {
+ t.Fatalf("opening fixture: %v", err)
+ }
+ t.Cleanup(func() { f.Close() })
+ return f
+}
+
+func TestIconsFromABinary(t *testing.T) {
+ groups, err := Icons(open(t, withIcons))
+ if err != nil {
+ t.Fatalf("reading icons: %v", err)
+ }
+ if len(groups) != 1 {
+ t.Fatalf("found %d groups, want 1", len(groups))
+ }
+ group := groups[0]
+ if group.ID != 1 {
+ t.Errorf("group id is %d, want 1", group.ID)
+ }
+ if want := []int{32, 24, 16}; !reflect.DeepEqual(group.Sizes, want) {
+ t.Errorf("sizes are %v, want %v", group.Sizes, want)
+ }
+}
+
+// TestICOMatchesWhatWentIn is the point of the package: the resource section
+// holds an ico taken apart, and putting it back together returns the file
+// the linker was given.
+func TestICOMatchesWhatWentIn(t *testing.T) {
+ groups, err := Icons(open(t, withIcons))
+ if err != nil {
+ t.Fatalf("reading icons: %v", err)
+ }
+ want, err := os.ReadFile(original)
+ if err != nil {
+ t.Fatalf("reading the original: %v", err)
+ }
+ if got := groups[0].ICO(); !bytes.Equal(got, want) {
+ t.Errorf("reassembled %d bytes, want the %d that went in", len(got), len(want))
+ }
+}
+
+func TestDecodeFromABinary(t *testing.T) {
+ img, err := Decode(open(t, withIcons))
+ if err != nil {
+ t.Fatalf("decoding: %v", err)
+ }
+ if got := img.Bounds().Size(); got.X != 32 || got.Y != 32 {
+ t.Fatalf("decoded a %v icon, want the largest at 32x32", got)
+ }
+ // Each size was drawn in its own colour, so the wrong one would show.
+ c := color.NRGBAModel.Convert(img.At(16, 16)).(color.NRGBA)
+ if want := (color.NRGBA{R: 0x10, G: 0x20, B: 0xE0, A: 0xFF}); c != want {
+ t.Errorf("centre is %v, want the 32 pixel artwork %v", c, want)
+ }
+}
+
+func TestIconsWithoutResources(t *testing.T) {
+ _, err := Icons(open(t, withoutIcons))
+ if !errors.Is(err, ErrNoIcons) {
+ t.Errorf("reading a binary with no icons returned %v, want ErrNoIcons", err)
+ }
+}
+
+func TestIconsRejectsWhatIsNotABinary(t *testing.T) {
+ if _, err := Icons(bytes.NewReader([]byte("not a binary"))); err == nil {
+ t.Error("reading rubbish returned no error")
+ }
+}
+
+// group builds a group icon directory naming the images given, so the error
+// paths can be reached without a binary that holds them.
+func group(id uint16, images ...uint16) leaf {
+ out := make([]byte, 0, groupHeaderSize+len(images)*groupEntrySize)
+ out = binary.LittleEndian.AppendUint16(out, 0)
+ out = binary.LittleEndian.AppendUint16(out, 1)
+ out = binary.LittleEndian.AppendUint16(out, uint16(len(images)))
+ for _, image := range images {
+ out = append(out, 32, 32, 0, 0)
+ out = binary.LittleEndian.AppendUint16(out, 1)
+ out = binary.LittleEndian.AppendUint16(out, 32)
+ out = binary.LittleEndian.AppendUint32(out, 16)
+ out = binary.LittleEndian.AppendUint16(out, image)
+ }
+ return leaf{id: id, data: out}
+}
+
+func TestAssembleRejectsAGroupItCannotComplete(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ group leaf
+ want string
+ }{
+ {
+ name: "names an image that is absent",
+ group: group(1, 7),
+ want: "names image 7",
+ },
+ {
+ name: "shorter than a header",
+ group: leaf{id: 1, data: []byte{0, 0}},
+ want: "holds 2 bytes",
+ },
+ {
+ name: "lists more icons than it holds",
+ group: leaf{id: 1, data: []byte{0, 0, 1, 0, 9, 0}},
+ want: "lists 9 icons",
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := assemble(tt.group, nil)
+ if err == nil {
+ t.Fatal("assembling returned no error")
+ }
+ if !errors.Is(err, ErrMalformed) {
+ t.Errorf("error is %v, want ErrMalformed", err)
+ }
+ if !strings.Contains(err.Error(), tt.want) {
+ t.Errorf("error is %v, want it to mention %q", err, tt.want)
+ }
+ })
+ }
+}
+
+// TestAssembleTakesTheLengthFromTheResource keeps a directory that misstates
+// a size from producing an ico nothing can read.
+func TestAssembleTakesTheLengthFromTheResource(t *testing.T) {
+ // The directory above says every image is 16 bytes; this one is not.
+ pixels := bytes.Repeat([]byte{0xAB}, 64)
+ got, err := assemble(group(1, 3), []leaf{{id: 3, data: pixels}})
+ if err != nil {
+ t.Fatalf("assembling: %v", err)
+ }
+ data := got.ICO()
+ row := data[groupHeaderSize:]
+ if size := binary.LittleEndian.Uint32(row[8:12]); int(size) != len(pixels) {
+ t.Errorf("row gives %d bytes, want the %d the resource holds", size, len(pixels))
+ }
+ if offset := binary.LittleEndian.Uint32(row[12:16]); int(offset) != groupHeaderSize+icoEntrySize {
+ t.Errorf("row points at %d, want the byte after the directory", offset)
+ }
+}
diff --git a/exe/reader.go b/exe/reader.go
@@ -0,0 +1,246 @@
+package exe
+
+import (
+ "bytes"
+ "cmp"
+ "debug/pe"
+ "encoding/binary"
+ "fmt"
+ "image"
+ "io"
+ "slices"
+
+ "github.com/jackmordaunt/icns/v4/ico"
+)
+
+// Icons returns the icons a Windows binary carries, lowest ordinal first,
+// which is the order Explorer draws them in.
+func Icons(r io.ReaderAt) ([]Group, error) {
+ file, err := pe.NewFile(r)
+ if err != nil {
+ return nil, fmt.Errorf("reading binary: %w", err)
+ }
+ defer file.Close()
+ section := file.Section(".rsrc")
+ if section == nil {
+ return nil, ErrNoIcons
+ }
+ data, err := section.Data()
+ if err != nil {
+ return nil, fmt.Errorf("reading resource section: %w", err)
+ }
+ // Section.Data returns the bytes on disk, which may be padded out past
+ // the size the section declares.
+ if int(section.VirtualSize) < len(data) {
+ data = data[:section.VirtualSize]
+ }
+ res := resources{data: data, base: section.VirtualAddress}
+ images, err := res.leaves(typeIcon)
+ if err != nil {
+ return nil, err
+ }
+ groups, err := res.leaves(typeIconGroup)
+ if err != nil {
+ return nil, err
+ }
+ if len(groups) == 0 {
+ return nil, ErrNoIcons
+ }
+ out := make([]Group, 0, len(groups))
+ for _, entry := range groups {
+ group, err := assemble(entry, images)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, group)
+ }
+ slices.SortStableFunc(out, func(a, b Group) int {
+ return cmp.Compare(a.ID, b.ID)
+ })
+ return out, nil
+}
+
+// Decode returns the largest icon in the binary that can be decoded.
+func Decode(r io.ReaderAt) (image.Image, error) {
+ groups, err := Icons(r)
+ if err != nil {
+ return nil, err
+ }
+ return groups[0].Decode()
+}
+
+// Decode returns the largest icon in the group that can be decoded.
+func (g Group) Decode() (image.Image, error) {
+ return ico.Decode(bytes.NewReader(g.ico))
+}
+
+// leaf is one resource: the ordinal it is filed under and its bytes.
+type leaf struct {
+ id uint16
+ data []byte
+}
+
+// resources walks the tree in a resource section. The tree is three levels
+// deep, by type, then by name or ordinal, then by language, and every offset
+// inside it is measured from the start of the section.
+type resources struct {
+ data []byte
+ base uint32
+}
+
+// leaves returns every resource of a type, taking the first language of each.
+func (res resources) leaves(kind uint32) ([]leaf, error) {
+ types, err := res.entries(0)
+ if err != nil {
+ return nil, err
+ }
+ var out []leaf
+ for _, t := range types {
+ if t.name != kind || !t.directory {
+ continue
+ }
+ named, err := res.entries(t.offset)
+ if err != nil {
+ return nil, err
+ }
+ for _, n := range named {
+ if !n.directory {
+ continue
+ }
+ languages, err := res.entries(n.offset)
+ if err != nil {
+ return nil, err
+ }
+ for _, l := range languages {
+ if l.directory {
+ continue
+ }
+ data, err := res.at(l.offset)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, leaf{id: uint16(n.name), data: data})
+ // One language is enough: the images are the same icon.
+ break
+ }
+ }
+ }
+ return out, nil
+}
+
+// entry is one row of a resource directory.
+type entry struct {
+ // name is the ordinal the resource is filed under, or the offset of its
+ // name when it has one rather than a number.
+ name uint32
+ // offset is where the row points, from the start of the section.
+ offset uint32
+ // directory reports whether the row points at another directory rather
+ // than at the bytes of a resource.
+ directory bool
+}
+
+// entries reads the rows of the resource directory at offset.
+func (res resources) entries(offset uint32) ([]entry, error) {
+ if int(offset)+directoryHeaderSize > len(res.data) {
+ return nil, fmt.Errorf("%w: a directory lies at %d, outside the section", ErrMalformed, offset)
+ }
+ var (
+ header = res.data[offset:]
+ named = int(binary.LittleEndian.Uint16(header[12:14]))
+ ids = int(binary.LittleEndian.Uint16(header[14:16]))
+ count = named + ids
+ at = int(offset) + directoryHeaderSize
+ )
+ if at+count*directoryEntrySize > len(res.data) {
+ return nil, fmt.Errorf("%w: a directory of %d entries runs past the section", ErrMalformed, count)
+ }
+ out := make([]entry, 0, count)
+ for i := 0; i < count; i++ {
+ row := res.data[at+i*directoryEntrySize:]
+ var (
+ name = binary.LittleEndian.Uint32(row[0:4])
+ target = binary.LittleEndian.Uint32(row[4:8])
+ )
+ out = append(out, entry{
+ // The high bit marks a name held as a string rather than an
+ // ordinal, which icons are not filed under.
+ name: name &^ 0x80000000,
+ offset: target &^ 0x80000000,
+ directory: target&0x80000000 != 0,
+ })
+ }
+ return out, nil
+}
+
+// at reads the resource the data entry at offset points to. The entry holds
+// an address in the loaded image, which the section's own address turns back
+// into a position in the file.
+func (res resources) at(offset uint32) ([]byte, error) {
+ if int(offset)+dataEntrySize > len(res.data) {
+ return nil, fmt.Errorf("%w: a data entry lies at %d, outside the section", ErrMalformed, offset)
+ }
+ var (
+ row = res.data[offset:]
+ address = binary.LittleEndian.Uint32(row[0:4])
+ size = binary.LittleEndian.Uint32(row[4:8])
+ )
+ if address < res.base {
+ return nil, fmt.Errorf("%w: a resource lies at %d, before the section", ErrMalformed, address)
+ }
+ start := address - res.base
+ if int(start)+int(size) > len(res.data) {
+ return nil, fmt.Errorf("%w: a resource of %d bytes at %d runs past the section", ErrMalformed, size, start)
+ }
+ return res.data[start : start+size], nil
+}
+
+// assemble turns a group icon directory and the images it names back into an
+// ico file. The two differ only in the last field of a row, where the group
+// names a resource and an ico gives the position of the image.
+func assemble(group leaf, images []leaf) (Group, error) {
+ if len(group.data) < groupHeaderSize {
+ return Group{}, fmt.Errorf("%w: icon group %d holds %d bytes", ErrMalformed, group.id, len(group.data))
+ }
+ count := int(binary.LittleEndian.Uint16(group.data[4:6]))
+ if groupHeaderSize+count*groupEntrySize > len(group.data) {
+ return Group{}, fmt.Errorf("%w: icon group %d lists %d icons it does not hold", ErrMalformed, group.id, count)
+ }
+ var (
+ rows = make([]byte, 0, groupHeaderSize+count*icoEntrySize)
+ body []byte
+ sizes []int
+ offset = groupHeaderSize + count*icoEntrySize
+ )
+ rows = binary.LittleEndian.AppendUint16(rows, 0)
+ rows = binary.LittleEndian.AppendUint16(rows, 1)
+ rows = binary.LittleEndian.AppendUint16(rows, uint16(count))
+ for i := 0; i < count; i++ {
+ row := group.data[groupHeaderSize+i*groupEntrySize:]
+ id := binary.LittleEndian.Uint16(row[12:14])
+ index := slices.IndexFunc(images, func(l leaf) bool { return l.id == id })
+ if index < 0 {
+ return Group{}, fmt.Errorf("%w: icon group %d names image %d, which is not present", ErrMalformed, group.id, id)
+ }
+ pixels := images[index].data
+ // The two rows agree up to the length of the image, which is taken
+ // from the resource itself rather than from the field that names it.
+ rows = append(rows, row[:8]...)
+ rows = binary.LittleEndian.AppendUint32(rows, uint32(len(pixels)))
+ rows = binary.LittleEndian.AppendUint32(rows, uint32(offset))
+ body = append(body, pixels...)
+ offset += len(pixels)
+
+ side := int(row[0])
+ if side == 0 {
+ side = 256
+ }
+ sizes = append(sizes, side)
+ }
+ slices.SortStableFunc(sizes, func(a, b int) int { return cmp.Compare(b, a) })
+ return Group{
+ ID: group.id,
+ Sizes: sizes,
+ ico: append(rows, body...),
+ }, nil
+}
diff --git a/exe/testdata/icon.dll b/exe/testdata/icon.dll
Binary files differ.
diff --git a/exe/testdata/icon.ico b/exe/testdata/icon.ico
Binary files differ.
diff --git a/exe/testdata/plain.dll b/exe/testdata/plain.dll
Binary files differ.