1use 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#[derive(Debug, Clone, Default)]
26pub struct Branding {
27 pub icon: Option<Vec<u8>>,
29 pub banner: Option<Vec<u8>>,
31 pub display_name: String,
32 pub publisher: String,
33 pub description: String,
34 pub version: String,
36 pub copyright: Option<String>,
38}
39
40pub 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 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
68fn 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 let mut fixed = Vec::with_capacity(52);
79 for dw in [
80 0xFEEF_04BDu32, 0x0001_0000, ver_ms, ver_ls, ver_ms, ver_ls, 0x3F, 0, 0x0004_0004, 1, 0, 0, 0, ] {
94 fixed.extend_from_slice(&dw.to_le_bytes());
95 }
96
97 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 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
126fn 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
133fn pad4(v: &mut Vec<u8>) {
136 while !v.len().is_multiple_of(4) {
137 v.push(0);
138 }
139}
140
141fn 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); }
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
159fn 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
166fn 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
178fn 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
192fn 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
210fn 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 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 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 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; 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); 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 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)); 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 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 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 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 assert!(
422 resources.get_manifest().unwrap().is_some(),
423 "pre-existing manifest intact"
424 );
425
426 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 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}