kanade_shared/exe_version.rs
1//! Extract the embedded `ProductVersion` / `FileVersion` string from
2//! a Windows PE binary's `VERSIONINFO` resource — the same data
3//! File Explorer's Details tab shows.
4//!
5//! Used by:
6//! * `kanade-backend`'s `POST /api/agents/publish` — auto-derives
7//! the Object-Store key from the uploaded bytes, so the operator
8//! can't typo a label that disagrees with the binary (the failure
9//! mode that caused the "rollout to 1.0.0 → endless self-update"
10//! incident on v0.13.0).
11//! * `kanade agent publish` on the CLI — replaces the spawn-based
12//! `--version` probe (which only worked on hosts that could
13//! execute the binary).
14//!
15//! Pure-`pelite`, no spawn / no OS-specific deps — works from any
16//! host (Linux operator uploading a Windows .exe is fine).
17
18/// Read the `ProductVersion` (falling back to `FileVersion`) from
19/// the VS_VERSIONINFO resource of a Windows PE. Returns `None` when:
20/// * the bytes aren't a valid PE32 / PE32+,
21/// * there's no `.rsrc` section,
22/// * there's no `VS_VERSIONINFO` resource, or
23/// * neither version string is set.
24///
25/// Tries PE32+ (64-bit) first, then PE32 (32-bit). Pelite's `pe`
26/// module re-export only resolves under `cfg(target_pointer_width
27/// = "64")` style gates that fail on the release pipeline's
28/// non-Windows runners, so we go through the explicit
29/// `pe64::PeFile` / `pe32::PeFile` paths instead.
30pub fn extract_pe_version(bytes: &[u8]) -> Option<String> {
31 if let Some(v) = try_pe64(bytes) {
32 return Some(v);
33 }
34 try_pe32(bytes)
35}
36
37fn try_pe64(bytes: &[u8]) -> Option<String> {
38 use pelite::pe64::{Pe, PeFile};
39 let pe = PeFile::from_bytes(bytes).ok()?;
40 let resources = pe.resources().ok()?;
41 let version_info = resources.version_info().ok()?;
42 for language in version_info.translation() {
43 if let Some(s) = version_info.value(*language, "ProductVersion")
44 && !s.is_empty()
45 {
46 return Some(normalise(&s));
47 }
48 if let Some(s) = version_info.value(*language, "FileVersion")
49 && !s.is_empty()
50 {
51 return Some(normalise(&s));
52 }
53 }
54 None
55}
56
57fn try_pe32(bytes: &[u8]) -> Option<String> {
58 use pelite::pe32::{Pe, PeFile};
59 let pe = PeFile::from_bytes(bytes).ok()?;
60 let resources = pe.resources().ok()?;
61 let version_info = resources.version_info().ok()?;
62 for language in version_info.translation() {
63 if let Some(s) = version_info.value(*language, "ProductVersion")
64 && !s.is_empty()
65 {
66 return Some(normalise(&s));
67 }
68 if let Some(s) = version_info.value(*language, "FileVersion")
69 && !s.is_empty()
70 {
71 return Some(normalise(&s));
72 }
73 }
74 None
75}
76
77/// Trim trailing nulls + whitespace. `winres`-written strings often
78/// have a single embedded NUL terminator the resource compiler keeps
79/// in the payload; pelite returns it raw.
80fn normalise(s: &str) -> String {
81 s.trim_end_matches(|c: char| c == '\0' || c.is_whitespace())
82 .to_string()
83}