icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

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:
M.gitignore | 4++++
Aexe/exe.go | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexe/exe_test.go | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexe/reader.go | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexe/testdata/icon.dll | 0
Aexe/testdata/icon.ico | 0
Aexe/testdata/plain.dll | 0
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.