Skip to main content

embala_setup/
brand.rs

1//! Build-time PE resource branding for the setup stub (spec R7).
2//!
3//! NSIS's own architecture: the prebuilt stub carries a generic icon and no
4//! version info; `embala build` patches its PE resources *before* the overlay is
5//! appended — never compiling. Patching is a pure function of `(stub, Branding)`
6//! so `setup.exe` stays byte-reproducible (spec R8). `uninstall.exe` is a prefix
7//! copy of the running stub and inherits the branding for free.
8//!
9//! editpe 0.2.3's structured `set_version_info` computes VS_VERSIONINFO header
10//! lengths from `str::len()` (UTF-8 bytes) while serializing strings as UTF-16,
11//! so any non-ASCII value (e.g. a "©" copyright) desyncs the structure and
12//! Windows drops the field. We take the plan's pre-authorized fallback: build
13//! the VS_VERSIONINFO blob by hand with correct UTF-16 unit counts and set it as
14//! a raw `RT_VERSION` id-1 resource. editpe still handles everything else.
15
16use std::io::Cursor;
17
18use editpe::constants::{LANGUAGE_ID_EN_US, RT_RCDATA, RT_VERSION};
19use editpe::{Image, ResourceData, ResourceEntry, ResourceEntryName, ResourceTable};
20
21use crate::{Error, Result};
22
23/// Product identity + optional bitmaps patched into the stub. Self-contained so
24/// the crate stays independent of the embala config types.
25#[derive(Debug, Clone, Default)]
26pub struct Branding {
27    /// PNG or ICO icon bytes; `Some` sets the stub's main icon (spec R7).
28    pub icon: Option<Vec<u8>>,
29    /// BMP banner bytes; `Some` adds the named `EMBALA_BANNER` RCDATA resource.
30    pub banner: Option<Vec<u8>>,
31    pub display_name: String,
32    pub publisher: String,
33    pub description: String,
34    /// Full version string (`ProductVersion`/`FileVersion` string values).
35    pub version: String,
36    /// `LegalCopyright`, emitted only when set.
37    pub copyright: Option<String>,
38}
39
40/// Patch `stub`'s PE resources per `branding` and return the rebuilt bytes.
41/// Always writes VERSIONINFO; sets the main icon / banner resource when supplied.
42pub fn patch_stub(stub: &[u8], branding: &Branding) -> Result<Vec<u8>> {
43    let mut image = Image::parse(stub).map_err(brand_err)?;
44    let mut resources = image.resource_directory().cloned().unwrap_or_default();
45
46    set_version_raw(&mut resources, &version_blob(branding));
47    if let Some(icon) = &branding.icon {
48        // set_main_icon_reader decodes PNG/ICO via the `images` feature and
49        // resizes to the standard resolutions.
50        resources
51            .set_main_icon_reader(&mut Cursor::new(icon))
52            .map_err(brand_err)?;
53    }
54    if let Some(banner) = &branding.banner {
55        set_named_rcdata(&mut resources, "EMBALA_BANNER", banner);
56    }
57
58    image.set_resource_directory(resources).map_err(brand_err)?;
59    let mut out = Vec::new();
60    image.write_writer(&mut out).map_err(brand_err)?;
61    Ok(out)
62}
63
64fn brand_err(e: impl std::fmt::Display) -> Error {
65    Error::Brand(e.to_string())
66}
67
68/// Build the VS_VERSIONINFO blob from the branding (spec R7). String table under
69/// the en-US/UTF-16 (`040904B0`) language, matching the `Translation` var. Every
70/// `wLength`/`wValueLength` is an exact count of the UTF-16-encoded bytes/units,
71/// which is the whole reason we assemble this by hand instead of via editpe.
72fn version_blob(b: &Branding) -> Vec<u8> {
73    let (major, minor, patch, build) = numeric_version(&b.version);
74    let ver_ms = ((major as u32) << 16) | minor as u32;
75    let ver_ls = ((patch as u32) << 16) | build as u32;
76
77    // VS_FIXEDFILEINFO — 13 DWORDs, 52 bytes; this is the root node's value.
78    let mut fixed = Vec::with_capacity(52);
79    for dw in [
80        0xFEEF_04BDu32, // dwSignature
81        0x0001_0000,    // dwStrucVersion 1.0
82        ver_ms,         // dwFileVersionMS
83        ver_ls,         // dwFileVersionLS
84        ver_ms,         // dwProductVersionMS
85        ver_ls,         // dwProductVersionLS
86        0x3F,           // dwFileFlagsMask
87        0,              // dwFileFlags
88        0x0004_0004,    // dwFileOS = VOS_NT_WINDOWS32
89        1,              // dwFileType = VFT_APP
90        0,              // dwFileSubtype
91        0,              // dwFileDateMS
92        0,              // dwFileDateLS
93    ] {
94        fixed.extend_from_slice(&dw.to_le_bytes());
95    }
96
97    // StringTable "040904B0" — same keys/order as before, copyright when set.
98    let mut strings: Vec<(&str, &str)> = vec![
99        ("ProductName", &b.display_name),
100        ("CompanyName", &b.publisher),
101        ("FileDescription", &b.description),
102        ("FileVersion", &b.version),
103        ("ProductVersion", &b.version),
104    ];
105    if let Some(copyright) = &b.copyright {
106        strings.push(("LegalCopyright", copyright));
107    }
108    let string_nodes: Vec<Vec<u8>> = strings.iter().map(|(k, v)| string_entry(k, v)).collect();
109    let string_table = node("040904B0", 0, 1, &concat_children(&string_nodes));
110    let string_file_info = node("StringFileInfo", 0, 1, &string_table);
111
112    // VarFileInfo / Translation = 0x0409 (en-US) + 0x04B0 (UTF-16 code page).
113    let mut translation = Vec::with_capacity(4);
114    translation.extend_from_slice(&0x0409u16.to_le_bytes());
115    translation.extend_from_slice(&0x04B0u16.to_le_bytes());
116    let var = node("Translation", translation.len() as u16, 0, &translation);
117    let var_file_info = node("VarFileInfo", 0, 1, &var);
118
119    let mut body = fixed;
120    body.extend_from_slice(&string_file_info);
121    pad4(&mut body);
122    body.extend_from_slice(&var_file_info);
123    node("VS_VERSION_INFO", 52, 0, &body)
124}
125
126/// UTF-16LE bytes of `s` with a nul terminator.
127fn utf16z(s: &str) -> Vec<u8> {
128    let mut out: Vec<u8> = s.encode_utf16().flat_map(u16::to_le_bytes).collect();
129    out.extend_from_slice(&[0, 0]);
130    out
131}
132
133/// Pad `v` up to the next 4-byte boundary (offsets are DWORD-aligned relative to
134/// the blob start, which is itself DWORD-aligned).
135fn pad4(v: &mut Vec<u8>) {
136    while !v.len().is_multiple_of(4) {
137        v.push(0);
138    }
139}
140
141/// A version node: `wLength`, `wValueLength`, `wType`, UTF-16 `szKey` + nul,
142/// pad to 4, then `body`. `wLength` counts the node through `body`, excluding any
143/// trailing pad that aligns a following sibling (the parent adds that).
144fn node(key: &str, value_len: u16, wtype: u16, body: &[u8]) -> Vec<u8> {
145    let mut inner = utf16z(key);
146    while !(6 + inner.len()).is_multiple_of(4) {
147        inner.push(0); // Padding1: DWORD-align the value/children after szKey.
148    }
149    inner.extend_from_slice(body);
150    let length = 6 + inner.len();
151    let mut out = Vec::with_capacity(length);
152    out.extend_from_slice(&(length as u16).to_le_bytes());
153    out.extend_from_slice(&value_len.to_le_bytes());
154    out.extend_from_slice(&wtype.to_le_bytes());
155    out.extend_from_slice(&inner);
156    out
157}
158
159/// One `String` entry. `wValueLength` is the value's UTF-16 unit count including
160/// the nul terminator — the count editpe gets wrong for non-ASCII values.
161fn string_entry(name: &str, value: &str) -> Vec<u8> {
162    let value_units = value.encode_utf16().count() as u16 + 1;
163    node(name, value_units, 1, &utf16z(value))
164}
165
166/// Concatenate child nodes, DWORD-aligning each after the previous.
167fn concat_children(nodes: &[Vec<u8>]) -> Vec<u8> {
168    let mut out = Vec::new();
169    for (i, n) in nodes.iter().enumerate() {
170        out.extend_from_slice(n);
171        if i + 1 < nodes.len() {
172            pad4(&mut out);
173        }
174    }
175    out
176}
177
178/// Numeric `a.b.c.d` VERSIONINFO tuple from a version string: the leading
179/// `major[.minor[.patch]]` components, the fourth always zero, and any
180/// unparseable component (or the whole string) collapsing to zero (spec R7).
181fn numeric_version(version: &str) -> (u16, u16, u16, u16) {
182    let mut parts = version.split('.');
183    let mut next = || {
184        parts
185            .next()
186            .and_then(|s| s.parse::<u16>().ok())
187            .unwrap_or(0)
188    };
189    (next(), next(), next(), 0)
190}
191
192/// Set `blob` as the raw `RT_VERSION` resource id 1 under en-US, replacing any
193/// version info the prebuilt stub carried (root → RT_VERSION → ID(1) → en-US).
194fn set_version_raw(resources: &mut editpe::ResourceDirectory, blob: &[u8]) {
195    let mut data = ResourceData::default();
196    data.set_data(blob.to_vec());
197    let mut lang = ResourceTable::default();
198    lang.insert(
199        ResourceEntryName::ID(LANGUAGE_ID_EN_US as u32),
200        ResourceEntry::Data(data),
201    );
202    let mut version = ResourceTable::default();
203    version.insert(ResourceEntryName::ID(1), ResourceEntry::Table(lang));
204    resources.root_mut().insert(
205        ResourceEntryName::ID(RT_VERSION as u32),
206        ResourceEntry::Table(version),
207    );
208}
209
210/// Add `data` as a named `RCDATA` resource (mirrors editpe's `set_manifest`
211/// nesting: root → RT_RCDATA table → named table → data under en-US).
212fn set_named_rcdata(resources: &mut editpe::ResourceDirectory, name: &str, data: &[u8]) {
213    let root = resources.root_mut();
214    if root.get(ResourceEntryName::ID(RT_RCDATA as u32)).is_none() {
215        root.insert(
216            ResourceEntryName::ID(RT_RCDATA as u32),
217            ResourceEntry::Table(ResourceTable::default()),
218        );
219    }
220    let table = match root
221        .get_mut(ResourceEntryName::ID(RT_RCDATA as u32))
222        .unwrap()
223    {
224        ResourceEntry::Table(t) => t,
225        ResourceEntry::Data(_) => unreachable!("RT_RCDATA is always a table"),
226    };
227
228    let mut inner = ResourceTable::default();
229    let mut entry = ResourceData::default();
230    entry.set_data(data.to_vec());
231    inner.insert(
232        ResourceEntryName::ID(LANGUAGE_ID_EN_US as u32),
233        ResourceEntry::Data(entry),
234    );
235    table.insert_at(
236        ResourceEntryName::from_string(name),
237        ResourceEntry::Table(inner),
238        0,
239    );
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::{SetupArch, stub_bytes};
246
247    const ICON_PNG: &[u8] = include_bytes!(concat!(
248        env!("CARGO_MANIFEST_DIR"),
249        "/../../fixtures/hello/icon.png"
250    ));
251    const BANNER_BMP: &[u8] = include_bytes!(concat!(
252        env!("CARGO_MANIFEST_DIR"),
253        "/../../fixtures/hello/banner.bmp"
254    ));
255
256    fn branding() -> Branding {
257        Branding {
258            icon: Some(ICON_PNG.to_vec()),
259            banner: Some(BANNER_BMP.to_vec()),
260            display_name: "Embala Hello".to_string(),
261            publisher: "Carlos Hernandez".to_string(),
262            description: "test fixture".to_string(),
263            version: "1.2.3".to_string(),
264            copyright: Some("© 2026 Carlos Hernandez".to_string()),
265        }
266    }
267
268    /// Fetch the raw RT_VERSION / id-1 / en-US resource bytes.
269    fn version_data(resources: &editpe::ResourceDirectory) -> Vec<u8> {
270        let table = match resources
271            .root()
272            .get(ResourceEntryName::ID(RT_VERSION as u32))
273            .expect("RT_VERSION present")
274        {
275            ResourceEntry::Table(t) => t,
276            _ => panic!("RT_VERSION is a table"),
277        };
278        let lang = match table.get(ResourceEntryName::ID(1)).expect("id 1 present") {
279            ResourceEntry::Table(t) => t,
280            _ => panic!("id 1 is a table"),
281        };
282        match lang
283            .get(ResourceEntryName::ID(LANGUAGE_ID_EN_US as u32))
284            .expect("en-US present")
285        {
286            ResourceEntry::Data(d) => d.data().to_vec(),
287            _ => panic!("en-US is data"),
288        }
289    }
290
291    fn rd_u16(b: &[u8], o: usize) -> usize {
292        u16::from_le_bytes([b[o], b[o + 1]]) as usize
293    }
294    fn align4(n: usize) -> usize {
295        n.div_ceil(4) * 4
296    }
297    /// Read a UTF-16 nul-terminated string; returns (value, offset past the nul).
298    fn read_sz(b: &[u8], mut o: usize) -> (String, usize) {
299        let mut units = Vec::new();
300        while rd_u16(b, o) != 0 {
301            units.push(rd_u16(b, o) as u16);
302            o += 2;
303        }
304        (String::from_utf16_lossy(&units), o + 2)
305    }
306
307    /// Walk the hand-built blob, asserting each `String`'s declared `wLength` /
308    /// `wValueLength` match the bytes actually written, and return name→value.
309    /// A byte-vs-unit desync (the editpe bug we route around) trips an assert.
310    fn parse_and_check(blob: &[u8]) -> std::collections::BTreeMap<String, String> {
311        assert_eq!(rd_u16(blob, 0), blob.len(), "root wLength spans the blob");
312        let (rkey, o) = read_sz(blob, 6);
313        assert_eq!(rkey, "VS_VERSION_INFO");
314        let mut o = align4(o) + 52; // skip Padding1 + VS_FIXEDFILEINFO.
315
316        let mut map = std::collections::BTreeMap::new();
317        let root_len = rd_u16(blob, 0);
318        while o < root_len {
319            let blen = rd_u16(blob, o);
320            let (bkey, p) = read_sz(blob, o + 6);
321            if bkey == "StringFileInfo" {
322                let mut p = align4(p);
323                while p < o + blen {
324                    let stlen = rd_u16(blob, p);
325                    let (_st, q) = read_sz(blob, p + 6);
326                    let mut q = align4(q);
327                    while q < p + stlen {
328                        let slen = rd_u16(blob, q);
329                        let vlen = rd_u16(blob, q + 2); // UTF-16 units incl. nul.
330                        let (name, after) = read_sz(blob, q + 6);
331                        let vo = align4(after);
332                        let (value, vend) = read_sz(blob, vo);
333                        let units = value.encode_utf16().count() + 1;
334                        assert_eq!(vlen, units, "wValueLength desync for {name}");
335                        assert_eq!(slen, vend - q, "wLength desync for {name}");
336                        map.insert(name, value);
337                        q = align4(q + slen);
338                    }
339                    p = align4(p + stlen);
340                }
341            }
342            o = align4(o + blen);
343        }
344        map
345    }
346
347    #[test]
348    fn version_blob_lengths_match_bytes() {
349        // Why: the raison d'être of the hand-built blob — non-ASCII values must
350        // declare UTF-16 *unit* counts, not UTF-8 byte counts (the editpe bug).
351        let b = Branding {
352            display_name: "Ünïcödé Ürünü".to_string(),
353            publisher: "Håkon &  Cie ©".to_string(),
354            description: "描述".to_string(),
355            version: "3.4.5".to_string(),
356            copyright: Some("© 2026 Carlos Hernández".to_string()),
357            ..Default::default()
358        };
359        let strings = parse_and_check(&version_blob(&b)); // asserts internally.
360        assert_eq!(
361            strings.get("CompanyName").map(String::as_str),
362            Some("Håkon &  Cie ©")
363        );
364        assert_eq!(
365            strings.get("LegalCopyright").map(String::as_str),
366            Some("© 2026 Carlos Hernández")
367        );
368    }
369
370    #[test]
371    fn numeric_version_cases() {
372        assert_eq!(numeric_version("1.2.3"), (1, 2, 3, 0));
373        assert_eq!(numeric_version("0.1.0-rc1"), (0, 1, 0, 0));
374        assert_eq!(numeric_version("garbage"), (0, 0, 0, 0));
375    }
376
377    #[test]
378    fn patching_is_deterministic() {
379        // Why: byte-reproducibility (spec R8) requires a pure patch — a hash-map
380        // iteration order or timestamp leak would break signing/caching.
381        let stub = stub_bytes(SetupArch::X86_64);
382        let a = patch_stub(stub, &branding()).unwrap();
383        let b = patch_stub(stub, &branding()).unwrap();
384        assert_eq!(a, b, "same inputs must patch to identical bytes");
385    }
386
387    #[test]
388    fn patched_stub_round_trips() {
389        // Why: the patched stub must re-parse as a valid PE carrying the icon,
390        // VERSIONINFO, and EMBALA_BANNER, with pre-existing resources (the stub's
391        // application manifest) left intact.
392        let patched = patch_stub(stub_bytes(SetupArch::X86_64), &branding()).unwrap();
393        let image = Image::parse(patched.as_slice()).expect("patched stub is a valid PE");
394        let resources = image
395            .resource_directory()
396            .expect("resource directory present");
397
398        // Parse our own raw RT_VERSION/id-1 blob (editpe's get_version_info shares
399        // the byte-vs-unit bug, so we can't trust it as an oracle).
400        let strings = parse_and_check(&version_data(resources));
401        assert_eq!(
402            strings.get("ProductName").map(String::as_str),
403            Some("Embala Hello")
404        );
405        assert_eq!(
406            strings.get("FileVersion").map(String::as_str),
407            Some("1.2.3")
408        );
409        assert_eq!(
410            strings.get("LegalCopyright").map(String::as_str),
411            Some("© 2026 Carlos Hernandez"),
412            "non-ASCII copyright must survive intact"
413        );
414
415        assert!(
416            resources.get_main_icon().unwrap().is_some(),
417            "main icon set"
418        );
419        // The stub ships an application manifest (setup.rc); patching must not
420        // drop it.
421        assert!(
422            resources.get_manifest().unwrap().is_some(),
423            "pre-existing manifest intact"
424        );
425
426        // EMBALA_BANNER lives under RT_RCDATA as a named entry.
427        let rcdata = resources
428            .root()
429            .get(ResourceEntryName::ID(RT_RCDATA as u32))
430            .and_then(|e| match e {
431                ResourceEntry::Table(t) => Some(t),
432                _ => None,
433            })
434            .expect("RCDATA table present");
435        assert!(
436            rcdata
437                .get(ResourceEntryName::from_string("EMBALA_BANNER"))
438                .is_some(),
439            "EMBALA_BANNER resource present"
440        );
441    }
442
443    #[test]
444    fn version_info_without_icon_or_banner() {
445        // Why: VERSIONINFO is always set even when no bitmaps are configured
446        // (spec R7); icon/banner stay absent.
447        let b = Branding {
448            version: "2.5.0".to_string(),
449            display_name: "Bare".to_string(),
450            ..Default::default()
451        };
452        let patched = patch_stub(stub_bytes(SetupArch::X86_64), &b).unwrap();
453        let image = Image::parse(patched.as_slice()).unwrap();
454        let resources = image.resource_directory().unwrap();
455        assert!(
456            resources
457                .root()
458                .get(ResourceEntryName::ID(RT_VERSION as u32))
459                .is_some(),
460            "VERSIONINFO always set"
461        );
462        assert!(
463            resources
464                .root()
465                .get(ResourceEntryName::ID(RT_RCDATA as u32))
466                .is_none(),
467            "no banner when unset"
468        );
469    }
470}