Skip to main content

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}