From 5bbe45ca306479da0f724818fa3a58a715a878a7 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Tue, 28 Apr 2026 18:41:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(infra):=20set=5Fexe=5Ficon=20=E2=80=94=20e?= =?UTF-8?q?mbed=20icono=20.ico=20en=20.exe=20Windows=20post-build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementacion Go pura sin dependencias externas (sin rcedit, wine, ni rsrc). Parsea ICONDIR + ICONDIRENTRY del .ico, construye un IMAGE_RESOURCE_DIRECTORY tree con RT_ICON + RT_GROUP_ICON, y appendea una nueva seccion .rsrc al PE. Soporta PE32 y PE32+. No soporta exe que ya tienen recursos (retorna error). Co-Authored-By: Claude Opus 4.7 (1M context) --- functions/infra/set_exe_icon.go | 406 ++++++++++++++++++++++++++++++++ functions/infra/set_exe_icon.md | 44 ++++ 2 files changed, 450 insertions(+) create mode 100644 functions/infra/set_exe_icon.go create mode 100644 functions/infra/set_exe_icon.md diff --git a/functions/infra/set_exe_icon.go b/functions/infra/set_exe_icon.go new file mode 100644 index 00000000..0ec67782 --- /dev/null +++ b/functions/infra/set_exe_icon.go @@ -0,0 +1,406 @@ +package infra + +import ( + "bytes" + "encoding/binary" + "fmt" + "os" +) + +const ( + rtIcon = 3 + rtGroupIcon = 14 + + scnCntInitializedData = 0x00000040 + scnMemRead = 0x40000000 + + dataDirResource = 2 + resourceTableSlot = 2 +) + +type icoEntry struct { + width uint8 + height uint8 + colors uint8 + reserved uint8 + planes uint16 + bitCount uint16 + size uint32 + offset uint32 + data []byte +} + +func parseICO(buf []byte) ([]icoEntry, error) { + if len(buf) < 6 { + return nil, fmt.Errorf("ico too short") + } + if binary.LittleEndian.Uint16(buf[0:2]) != 0 { + return nil, fmt.Errorf("ico reserved field must be 0") + } + if binary.LittleEndian.Uint16(buf[2:4]) != 1 { + return nil, fmt.Errorf("not an icon (type != 1)") + } + count := int(binary.LittleEndian.Uint16(buf[4:6])) + if count == 0 { + return nil, fmt.Errorf("ico has 0 images") + } + if len(buf) < 6+16*count { + return nil, fmt.Errorf("ico header truncated") + } + entries := make([]icoEntry, count) + for i := 0; i < count; i++ { + off := 6 + 16*i + e := icoEntry{ + width: buf[off], + height: buf[off+1], + colors: buf[off+2], + reserved: buf[off+3], + planes: binary.LittleEndian.Uint16(buf[off+4 : off+6]), + bitCount: binary.LittleEndian.Uint16(buf[off+6 : off+8]), + size: binary.LittleEndian.Uint32(buf[off+8 : off+12]), + offset: binary.LittleEndian.Uint32(buf[off+12 : off+16]), + } + if int(e.offset)+int(e.size) > len(buf) { + return nil, fmt.Errorf("ico image %d out of bounds", i) + } + e.data = buf[e.offset : e.offset+e.size] + entries[i] = e + } + return entries, nil +} + +type peLayout struct { + data []byte + peOff int + is64 bool + numSections int + sizeOptHdr int + sectionAlign uint32 + fileAlign uint32 + sectionsStart int + sizeOfImage uint32 + dataDirCount uint32 + dataDirOff int + checksumOff int + sizeOfImageOff int +} + +func parsePE(buf []byte) (*peLayout, error) { + if len(buf) < 64 || buf[0] != 'M' || buf[1] != 'Z' { + return nil, fmt.Errorf("not a PE file (no MZ signature)") + } + peOff := int(binary.LittleEndian.Uint32(buf[0x3C:0x40])) + if peOff+24 > len(buf) { + return nil, fmt.Errorf("PE header out of bounds") + } + if string(buf[peOff:peOff+4]) != "PE\x00\x00" { + return nil, fmt.Errorf("not a PE file (no PE signature at e_lfanew)") + } + coff := peOff + 4 + numSections := int(binary.LittleEndian.Uint16(buf[coff+2 : coff+4])) + sizeOptHdr := int(binary.LittleEndian.Uint16(buf[coff+16 : coff+18])) + optOff := coff + 20 + if optOff+sizeOptHdr > len(buf) { + return nil, fmt.Errorf("optional header truncated") + } + magic := binary.LittleEndian.Uint16(buf[optOff : optOff+2]) + var is64 bool + switch magic { + case 0x10B: + is64 = false + case 0x20B: + is64 = true + default: + return nil, fmt.Errorf("unknown optional header magic 0x%X", magic) + } + + var sectionAlignOff, fileAlignOff, sizeOfImageOff, checksumOff, numRvaOff, dataDirOff int + if is64 { + sectionAlignOff = optOff + 32 + fileAlignOff = optOff + 36 + sizeOfImageOff = optOff + 56 + checksumOff = optOff + 64 + numRvaOff = optOff + 108 + dataDirOff = optOff + 112 + } else { + sectionAlignOff = optOff + 32 + fileAlignOff = optOff + 36 + sizeOfImageOff = optOff + 56 + checksumOff = optOff + 64 + numRvaOff = optOff + 92 + dataDirOff = optOff + 96 + } + dataDirCount := binary.LittleEndian.Uint32(buf[numRvaOff : numRvaOff+4]) + + return &peLayout{ + data: buf, + peOff: peOff, + is64: is64, + numSections: numSections, + sizeOptHdr: sizeOptHdr, + sectionAlign: binary.LittleEndian.Uint32(buf[sectionAlignOff : sectionAlignOff+4]), + fileAlign: binary.LittleEndian.Uint32(buf[fileAlignOff : fileAlignOff+4]), + sectionsStart: optOff + sizeOptHdr, + sizeOfImage: binary.LittleEndian.Uint32(buf[sizeOfImageOff : sizeOfImageOff+4]), + dataDirCount: dataDirCount, + dataDirOff: dataDirOff, + checksumOff: checksumOff, + sizeOfImageOff: sizeOfImageOff, + }, nil +} + +type sectionHdr struct { + name string + virtualSize uint32 + virtualAddress uint32 + sizeOfRawData uint32 + pointerToRawData uint32 + characteristics uint32 + headerOff int +} + +func (p *peLayout) sections() []sectionHdr { + out := make([]sectionHdr, p.numSections) + for i := 0; i < p.numSections; i++ { + off := p.sectionsStart + 40*i + nameBytes := p.data[off : off+8] + end := bytes.IndexByte(nameBytes, 0) + if end < 0 { + end = 8 + } + out[i] = sectionHdr{ + name: string(nameBytes[:end]), + virtualSize: binary.LittleEndian.Uint32(p.data[off+8 : off+12]), + virtualAddress: binary.LittleEndian.Uint32(p.data[off+12 : off+16]), + sizeOfRawData: binary.LittleEndian.Uint32(p.data[off+16 : off+20]), + pointerToRawData: binary.LittleEndian.Uint32(p.data[off+20 : off+24]), + characteristics: binary.LittleEndian.Uint32(p.data[off+36 : off+40]), + headerOff: off, + } + } + return out +} + +func (p *peLayout) hasRsrc() bool { + if p.dataDirCount > resourceTableSlot { + off := p.dataDirOff + resourceTableSlot*8 + va := binary.LittleEndian.Uint32(p.data[off : off+4]) + size := binary.LittleEndian.Uint32(p.data[off+4 : off+8]) + if va != 0 && size != 0 { + return true + } + } + for _, s := range p.sections() { + if s.name == ".rsrc" { + return true + } + } + return false +} + +func alignUp(v, a uint32) uint32 { + if a == 0 { + return v + } + return (v + a - 1) &^ (a - 1) +} + +func buildResourceSection(entries []icoEntry, baseRVA uint32) ([]byte, error) { + n := uint32(len(entries)) + + rootDirSize := uint32(16 + 2*8) + rtIconDirSize := uint32(16 + n*8) + perIconNameDirSize := uint32(16 + 8) + rtGroupDirSize := uint32(16 + 8) + groupNameDirSize := uint32(16 + 8) + + leafEntrySize := uint32(16) + totalLeafEntries := n + 1 + leavesSize := leafEntrySize * totalLeafEntries + + dirsSize := rootDirSize + rtIconDirSize + n*perIconNameDirSize + rtGroupDirSize + groupNameDirSize + + dirsAndLeaves := dirsSize + leavesSize + + groupDataSize := uint32(6 + 14*n) + dataStartOff := dirsAndLeaves + groupDataOff := dataStartOff + groupDataPadded := alignUp(groupDataSize, 4) + + iconDataOffsets := make([]uint32, n) + cursor := groupDataOff + groupDataPadded + for i, e := range entries { + iconDataOffsets[i] = cursor + cursor += alignUp(uint32(len(e.data)), 4) + } + totalSize := cursor + + out := make([]byte, totalSize) + + leafOff := dirsSize + groupLeafOff := leafOff + iconLeavesStart := leafOff + leafEntrySize + + rootOff := uint32(0) + rtGroupSubOff := rootDirSize + rtIconSubOff := rtGroupSubOff + rtGroupDirSize + groupNameSubOff := rtIconSubOff + rtIconDirSize + perIconNamesStart := groupNameSubOff + groupNameDirSize + + writeDirHeader := func(off uint32, idEntries uint16) { + binary.LittleEndian.PutUint32(out[off:off+4], 0) + binary.LittleEndian.PutUint32(out[off+4:off+8], 0) + binary.LittleEndian.PutUint16(out[off+8:off+10], 0) + binary.LittleEndian.PutUint16(out[off+10:off+12], 0) + binary.LittleEndian.PutUint16(out[off+12:off+14], 0) + binary.LittleEndian.PutUint16(out[off+14:off+16], idEntries) + } + writeIDEntry := func(off uint32, id uint32, target uint32, isDir bool) { + binary.LittleEndian.PutUint32(out[off:off+4], id) + val := target + if isDir { + val |= 0x80000000 + } + binary.LittleEndian.PutUint32(out[off+4:off+8], val) + } + + writeDirHeader(rootOff, 2) + writeIDEntry(rootOff+16, rtIcon, rtIconSubOff, true) + writeIDEntry(rootOff+24, rtGroupIcon, rtGroupSubOff, true) + + writeDirHeader(rtGroupSubOff, 1) + writeIDEntry(rtGroupSubOff+16, 1, groupNameSubOff, true) + + writeDirHeader(groupNameSubOff, 1) + writeIDEntry(groupNameSubOff+16, 0, groupLeafOff, false) + + writeDirHeader(rtIconSubOff, uint16(n)) + for i := uint32(0); i < n; i++ { + writeIDEntry(rtIconSubOff+16+i*8, i+1, perIconNamesStart+i*perIconNameDirSize, true) + } + for i := uint32(0); i < n; i++ { + nameDirOff := perIconNamesStart + i*perIconNameDirSize + writeDirHeader(nameDirOff, 1) + leafForIcon := iconLeavesStart + i*leafEntrySize + writeIDEntry(nameDirOff+16, 0, leafForIcon, false) + } + + writeLeaf := func(off uint32, dataOff uint32, size uint32) { + binary.LittleEndian.PutUint32(out[off:off+4], baseRVA+dataOff) + binary.LittleEndian.PutUint32(out[off+4:off+8], size) + binary.LittleEndian.PutUint32(out[off+8:off+12], 0) + binary.LittleEndian.PutUint32(out[off+12:off+16], 0) + } + + writeLeaf(groupLeafOff, groupDataOff, groupDataSize) + for i := uint32(0); i < n; i++ { + writeLeaf(iconLeavesStart+i*leafEntrySize, iconDataOffsets[i], uint32(len(entries[i].data))) + } + + binary.LittleEndian.PutUint16(out[groupDataOff:groupDataOff+2], 0) + binary.LittleEndian.PutUint16(out[groupDataOff+2:groupDataOff+4], 1) + binary.LittleEndian.PutUint16(out[groupDataOff+4:groupDataOff+6], uint16(n)) + for i, e := range entries { + eo := groupDataOff + 6 + uint32(i)*14 + out[eo] = e.width + out[eo+1] = e.height + out[eo+2] = e.colors + out[eo+3] = e.reserved + binary.LittleEndian.PutUint16(out[eo+4:eo+6], e.planes) + binary.LittleEndian.PutUint16(out[eo+6:eo+8], e.bitCount) + binary.LittleEndian.PutUint32(out[eo+8:eo+12], e.size) + binary.LittleEndian.PutUint16(out[eo+12:eo+14], uint16(i+1)) + } + + for i, e := range entries { + copy(out[iconDataOffsets[i]:], e.data) + } + + return out, nil +} + +// SetExeIcon embebe el icono del archivo .ico en el .exe sobreescribiendo +// el archivo. Funciona para PE32 y PE32+ que aun no tienen seccion .rsrc +// (caso comun de binarios Go compilados sin icono). Si el .exe ya tiene +// recursos retorna error. +func SetExeIcon(exePath, icoPath string) error { + icoBuf, err := os.ReadFile(icoPath) + if err != nil { + return fmt.Errorf("read ico: %w", err) + } + entries, err := parseICO(icoBuf) + if err != nil { + return fmt.Errorf("parse ico: %w", err) + } + + exeBuf, err := os.ReadFile(exePath) + if err != nil { + return fmt.Errorf("read exe: %w", err) + } + pe, err := parsePE(exeBuf) + if err != nil { + return fmt.Errorf("parse pe: %w", err) + } + if pe.hasRsrc() { + return fmt.Errorf("exe already has .rsrc resources; not supported") + } + if pe.dataDirCount <= resourceTableSlot { + return fmt.Errorf("optional header DataDirectory has only %d entries (need >= %d)", pe.dataDirCount, resourceTableSlot+1) + } + + sections := pe.sections() + if len(sections) == 0 { + return fmt.Errorf("exe has no sections") + } + last := sections[len(sections)-1] + + newSecHdrOff := pe.sectionsStart + 40*pe.numSections + if newSecHdrOff+40 > int(last.pointerToRawData) { + return fmt.Errorf("not enough space in PE headers for new section header") + } + + newRVA := alignUp(last.virtualAddress+last.virtualSize, pe.sectionAlign) + newRawOff := alignUp(last.pointerToRawData+last.sizeOfRawData, pe.fileAlign) + + rsrc, err := buildResourceSection(entries, newRVA) + if err != nil { + return fmt.Errorf("build resource section: %w", err) + } + rawSize := alignUp(uint32(len(rsrc)), pe.fileAlign) + virtSize := uint32(len(rsrc)) + + out := make([]byte, 0, int(newRawOff)+int(rawSize)) + out = append(out, exeBuf[:newRawOff]...) + if int(newRawOff) > len(exeBuf) { + out = append(out, make([]byte, int(newRawOff)-len(exeBuf))...) + } + out = append(out, rsrc...) + if rawSize > uint32(len(rsrc)) { + out = append(out, make([]byte, rawSize-uint32(len(rsrc)))...) + } + + hdr := make([]byte, 40) + copy(hdr[0:8], ".rsrc\x00\x00\x00") + binary.LittleEndian.PutUint32(hdr[8:12], virtSize) + binary.LittleEndian.PutUint32(hdr[12:16], newRVA) + binary.LittleEndian.PutUint32(hdr[16:20], rawSize) + binary.LittleEndian.PutUint32(hdr[20:24], newRawOff) + binary.LittleEndian.PutUint32(hdr[36:40], scnCntInitializedData|scnMemRead) + copy(out[newSecHdrOff:newSecHdrOff+40], hdr) + + binary.LittleEndian.PutUint16(out[pe.peOff+4+2:pe.peOff+4+4], uint16(pe.numSections+1)) + + rsrcEntryOff := pe.dataDirOff + resourceTableSlot*8 + binary.LittleEndian.PutUint32(out[rsrcEntryOff:rsrcEntryOff+4], newRVA) + binary.LittleEndian.PutUint32(out[rsrcEntryOff+4:rsrcEntryOff+8], virtSize) + + newSizeOfImage := alignUp(newRVA+virtSize, pe.sectionAlign) + binary.LittleEndian.PutUint32(out[pe.sizeOfImageOff:pe.sizeOfImageOff+4], newSizeOfImage) + + binary.LittleEndian.PutUint32(out[pe.checksumOff:pe.checksumOff+4], 0) + + if err := os.WriteFile(exePath, out, 0o755); err != nil { + return fmt.Errorf("write exe: %w", err) + } + return nil +} diff --git a/functions/infra/set_exe_icon.md b/functions/infra/set_exe_icon.md new file mode 100644 index 00000000..b63dae2a --- /dev/null +++ b/functions/infra/set_exe_icon.md @@ -0,0 +1,44 @@ +--- +name: set_exe_icon +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SetExeIcon(exePath, icoPath string) error" +description: "Embebe un icono (.ico multi-tamaño) en un ejecutable PE Windows post-build. Implementacion Go pura sin dependencias externas (sin rcedit/wine/rsrc). Parsea el ICONDIR + ICONDIRENTRY del .ico, construye un IMAGE_RESOURCE_DIRECTORY tree con RT_ICON + RT_GROUP_ICON, y appendea una nueva seccion .rsrc al PE. Soporta PE32 y PE32+. No soporta exe que ya tienen recursos (retorna error)." +tags: [windows, pe, exe, icon, rcedit, post-build] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [bytes, encoding/binary, fmt, os] +params: + - name: exePath + desc: "ruta absoluta o relativa al .exe Windows a modificar (se sobreescribe in-place)" + - name: icoPath + desc: "ruta al archivo .ico con uno o mas tamaños de icono" +output: "nil si el icono se embebio correctamente; error si el .exe ya tiene recursos, no es PE valido, o el .ico es invalido" +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/set_exe_icon.go" +--- + +## Ejemplo + +```go +err := infra.SetExeIcon("myapp.exe", "logo.ico") +if err != nil { + log.Fatal(err) +} +``` + +## Notas + +- Solo Go binaries cross-compiled a Windows que **no** tengan seccion `.rsrc` previa. La mayoria de binarios Go limpios no la tienen. +- Si el .exe ya tiene recursos (creado con `goversioninfo`, `rsrc`, MSVC, etc.), retorna error y no modifica el archivo. +- El checksum del PE se pone a 0 tras la modificacion (Windows lo ignora para .exe normales; firmas Authenticode quedarian invalidadas). +- Soporta multi-resolucion: si el .ico tiene 16x16, 32x32, 256x256... todos se embeben y Windows elige el mejor. +- El icono cambia tras refrescar la cache de iconos de Explorer (a veces requiere `ie4uinit -show` o reiniciar Explorer).