Skip to main content

kanade_shared/
bin_platform.rs

1//! Which platform an agent binary targets, read from its own bytes — the
2//! single source of truth for the `agent_releases` Object Store key scheme.
3//!
4//! Key scheme (backward compatible):
5//!
6//!   * Windows releases stay at the **bare `<version>`** key — agents in
7//!     the field fetch exactly that today, so any change would be a
8//!     migration. (Windows aarch64 also maps to the bare key: the fleet is
9//!     x86_64 in practice, and distinguishing it is a future problem.)
10//!   * Linux releases live at **`<version>-linux-<arch>`** (`x86_64` /
11//!     `aarch64`). Semver prerelease dashes are fine because consumers
12//!     always match the *suffix* (`-linux-x86_64` / `-linux-aarch64`),
13//!     never a dash mid-version.
14//!
15//! Detection is by magic bytes, not filename: `MZ` → PE (arch from the
16//! COFF header's Machine field), `\x7fELF` → ELF (arch from `e_machine`),
17//! Mach-O magics → a clear "unsupported" error (macOS agents are out of
18//! scope). Pure byte inspection, no parsing library — the PE path only
19//! needs `e_lfanew` + the COFF Machine field, which `pelite` (used for
20//! VERSIONINFO in `exe_version.rs`) doesn't surface as a bare number
21//! without dragging in its full header model.
22
23/// Object Store key suffix for Linux x86_64 releases.
24pub const LINUX_SUFFIX_X86_64: &str = "-linux-x86_64";
25/// Object Store key suffix for Linux aarch64 releases.
26pub const LINUX_SUFFIX_AARCH64: &str = "-linux-aarch64";
27/// Both Linux suffixes, for "any Linux key" scans (rollout checks).
28pub const LINUX_SUFFIXES: [&str; 2] = [LINUX_SUFFIX_X86_64, LINUX_SUFFIX_AARCH64];
29
30/// The platform an uploaded agent binary runs on.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum AgentPlatform {
33    WindowsX86_64,
34    WindowsAarch64,
35    LinuxX86_64,
36    LinuxAarch64,
37}
38
39impl AgentPlatform {
40    /// Identify the platform from the binary's leading bytes. Errors
41    /// (rather than guesses) on Mach-O, on a recognized container with an
42    /// unsupported architecture, and on anything unrecognized — a publish
43    /// that can't name its platform must not silently land on the Windows
44    /// key.
45    pub fn detect(bytes: &[u8]) -> Result<AgentPlatform, String> {
46        if bytes.starts_with(b"MZ") {
47            return detect_pe(bytes);
48        }
49        if bytes.starts_with(b"\x7fELF") {
50            return detect_elf(bytes);
51        }
52        if is_macho(bytes) {
53            return Err(
54                "macOS (Mach-O) agents are not supported — kanade-agent ships for Windows and \
55                 Linux only"
56                    .to_string(),
57            );
58        }
59        Err(
60            "unrecognized binary format (not a Windows PE or Linux ELF) — is this a kanade-agent \
61             build?"
62                .to_string(),
63        )
64    }
65
66    /// The `agent_releases` Object Store key for `version` on this
67    /// platform. Windows → the bare version (full backward compatibility);
68    /// Linux → the arch-suffixed key.
69    pub fn release_key(&self, version: &str) -> String {
70        match self.suffix() {
71            Some(suffix) => format!("{version}{suffix}"),
72            None => version.to_string(),
73        }
74    }
75
76    /// The key suffix for this platform, `None` for Windows (bare key).
77    pub fn suffix(&self) -> Option<&'static str> {
78        match self {
79            AgentPlatform::WindowsX86_64 | AgentPlatform::WindowsAarch64 => None,
80            AgentPlatform::LinuxX86_64 => Some(LINUX_SUFFIX_X86_64),
81            AgentPlatform::LinuxAarch64 => Some(LINUX_SUFFIX_AARCH64),
82        }
83    }
84
85    /// Human label for audit records / logs (`"windows-x86_64"`, …).
86    pub fn as_str(&self) -> &'static str {
87        match self {
88            AgentPlatform::WindowsX86_64 => "windows-x86_64",
89            AgentPlatform::WindowsAarch64 => "windows-aarch64",
90            AgentPlatform::LinuxX86_64 => "linux-x86_64",
91            AgentPlatform::LinuxAarch64 => "linux-aarch64",
92        }
93    }
94}
95
96/// The platform label for an existing store key, derived from its suffix:
97/// `"linux-x86_64"` / `"linux-aarch64"` for suffixed keys, `"windows"` for
98/// anything else (bare keys are Windows by definition of the key scheme).
99/// Used by the releases listing, which only has keys to look at.
100pub fn platform_of_key(key: &str) -> &'static str {
101    if key.ends_with(LINUX_SUFFIX_X86_64) {
102        "linux-x86_64"
103    } else if key.ends_with(LINUX_SUFFIX_AARCH64) {
104        "linux-aarch64"
105    } else {
106        "windows"
107    }
108}
109
110/// The rollout-visible version for a store key: the key with any linux
111/// platform suffix stripped. A scope's `target_version` always names the
112/// BASE version (`0.46.0`, never `0.46.0-linux-x86_64`) — the agent's own
113/// platform decides which suffixed binary it fetches — so guards that
114/// compare a key against `target_version` must go through this.
115pub fn base_version_of_key(key: &str) -> &str {
116    for suffix in LINUX_SUFFIXES {
117        if let Some(base) = key.strip_suffix(suffix) {
118            return base;
119        }
120    }
121    key
122}
123
124/// Every store key a rollout's existence check should accept for
125/// `version`: the bare (Windows) key plus each linux platform key. A
126/// version is rollable-out when ANY of these exists — a Linux-only
127/// publish never writes the bare key.
128pub fn candidate_keys(version: &str) -> Vec<String> {
129    let mut keys = Vec::with_capacity(1 + LINUX_SUFFIXES.len());
130    keys.push(version.to_string());
131    for suffix in LINUX_SUFFIXES {
132        keys.push(format!("{version}{suffix}"));
133    }
134    keys
135}
136
137/// The charset every `agent_releases` key must fit: the key reaches a
138/// quoted `Content-Disposition` filename, generated PowerShell / batch /
139/// shell install scripts, and NATS subjects — restrict the charset
140/// (semver-ish) rather than escaping four different formats. Shared by the
141/// backend publish endpoint, the CLI publish, and the installer endpoint
142/// (which used to carry its own copy).
143pub fn check_release_key(key: &str) -> Result<(), String> {
144    if key.is_empty()
145        || !key
146            .chars()
147            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
148    {
149        return Err(format!(
150            "release key must be non-empty and contain only [A-Za-z0-9._+-], got {key:?}"
151        ));
152    }
153    Ok(())
154}
155
156/// PE: `MZ` DOS stub, `e_lfanew` (u32 LE at 0x3C) → `"PE\0\0"` signature,
157/// COFF Machine field (u16 LE right after). 0x8664 = x86_64, 0xAA64 =
158/// aarch64 (ARM64EC uses the same field for our purposes — a native ARM64
159/// agent build reports 0xAA64).
160fn detect_pe(bytes: &[u8]) -> Result<AgentPlatform, String> {
161    let truncated = || "truncated PE header".to_string();
162    let pe_off = u32::from_le_bytes(
163        bytes
164            .get(0x3C..0x40)
165            .ok_or_else(truncated)?
166            .try_into()
167            .map_err(|_| truncated())?,
168    ) as usize;
169    let coff = bytes.get(pe_off..pe_off + 6).ok_or_else(truncated)?;
170    if coff[..4] != *b"PE\0\0" {
171        return Err("MZ binary without a PE signature".to_string());
172    }
173    match u16::from_le_bytes([coff[4], coff[5]]) {
174        0x8664 => Ok(AgentPlatform::WindowsX86_64),
175        0xAA64 => Ok(AgentPlatform::WindowsAarch64),
176        other => Err(format!(
177            "unsupported PE machine type 0x{other:04X} (kanade-agent ships x86_64 and aarch64 only)"
178        )),
179    }
180}
181
182/// ELF: magic, then `e_machine` (u16 at offset 18) read with the
183/// endianness `EI_DATA` (offset 5) declares. 62 = EM_X86_64, 183 =
184/// EM_AARCH64.
185fn detect_elf(bytes: &[u8]) -> Result<AgentPlatform, String> {
186    let truncated = || "truncated ELF header".to_string();
187    let ei_data = bytes.get(5).ok_or_else(truncated)?;
188    let em = bytes.get(18..20).ok_or_else(truncated)?;
189    let machine = match ei_data {
190        1 => u16::from_le_bytes([em[0], em[1]]),
191        2 => u16::from_be_bytes([em[0], em[1]]),
192        other => return Err(format!("unknown ELF endianness {other}")),
193    };
194    match machine {
195        62 => Ok(AgentPlatform::LinuxX86_64),
196        183 => Ok(AgentPlatform::LinuxAarch64),
197        other => Err(format!(
198            "unsupported ELF machine {other} (kanade-agent ships x86_64 (62) and aarch64 (183) \
199             only)"
200        )),
201    }
202}
203
204/// Mach-O magics, both endiannesses, 32/64-bit, plus the fat (universal)
205/// wrappers — anything Apple is out of scope, so they all funnel to the
206/// same "unsupported" error rather than a misdetection.
207fn is_macho(bytes: &[u8]) -> bool {
208    const MAGICS: [[u8; 4]; 8] = [
209        [0xFE, 0xED, 0xFA, 0xCE], // MH_MAGIC (32-bit, BE)
210        [0xCE, 0xFA, 0xED, 0xFE], // MH_CIGAM (32-bit, LE)
211        [0xFE, 0xED, 0xFA, 0xCF], // MH_MAGIC_64 (BE)
212        [0xCF, 0xFA, 0xED, 0xFE], // MH_CIGAM_64 (LE)
213        [0xCA, 0xFE, 0xBA, 0xBE], // FAT_MAGIC (universal, BE)
214        [0xBE, 0xBA, 0xFE, 0xCA], // FAT_CIGAM
215        [0xCA, 0xFE, 0xBA, 0xBF], // FAT_MAGIC_64
216        [0xBF, 0xBA, 0xFE, 0xCA], // FAT_CIGAM_64
217    ];
218    MAGICS.iter().any(|m| bytes.starts_with(m))
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    /// Minimal MZ+PE: DOS stub with e_lfanew at 0x80, signature + Machine.
226    fn fake_pe(machine: u16) -> Vec<u8> {
227        let mut b = vec![0u8; 0x80 + 6];
228        b[0] = b'M';
229        b[1] = b'Z';
230        b[0x3C..0x40].copy_from_slice(&0x80u32.to_le_bytes());
231        b[0x80..0x84].copy_from_slice(b"PE\0\0");
232        b[0x84..0x86].copy_from_slice(&machine.to_le_bytes());
233        b
234    }
235
236    /// Minimal ELF ident + e_machine.
237    fn fake_elf(machine: u16, little_endian: bool) -> Vec<u8> {
238        let mut b = vec![0u8; 20];
239        b[..4].copy_from_slice(b"\x7fELF");
240        b[4] = 2; // ELFCLASS64
241        b[5] = if little_endian { 1 } else { 2 };
242        if little_endian {
243            b[18..20].copy_from_slice(&machine.to_le_bytes());
244        } else {
245            b[18..20].copy_from_slice(&machine.to_be_bytes());
246        }
247        b
248    }
249
250    #[test]
251    fn detects_pe_architectures() {
252        assert_eq!(
253            AgentPlatform::detect(&fake_pe(0x8664)).unwrap(),
254            AgentPlatform::WindowsX86_64
255        );
256        assert_eq!(
257            AgentPlatform::detect(&fake_pe(0xAA64)).unwrap(),
258            AgentPlatform::WindowsAarch64
259        );
260        // An MZ with a machine we don't ship is an error, not a guess.
261        assert!(AgentPlatform::detect(&fake_pe(0x14C)).is_err()); // i386
262        // MZ but no PE signature (a DOS binary) is an error too.
263        let mut b = fake_pe(0x8664);
264        b[0x80] = b'X';
265        assert!(AgentPlatform::detect(&b).is_err());
266        // Truncated just past MZ.
267        assert!(AgentPlatform::detect(b"MZ").is_err());
268    }
269
270    #[test]
271    fn detects_elf_architectures_and_endianness() {
272        assert_eq!(
273            AgentPlatform::detect(&fake_elf(62, true)).unwrap(),
274            AgentPlatform::LinuxX86_64
275        );
276        assert_eq!(
277            AgentPlatform::detect(&fake_elf(183, true)).unwrap(),
278            AgentPlatform::LinuxAarch64
279        );
280        // e_machine honors EI_DATA — a big-endian-encoded aarch64 still
281        // reads as aarch64.
282        assert_eq!(
283            AgentPlatform::detect(&fake_elf(183, false)).unwrap(),
284            AgentPlatform::LinuxAarch64
285        );
286        assert!(AgentPlatform::detect(&fake_elf(40, true)).is_err()); // ARM 32
287        assert!(AgentPlatform::detect(b"\x7fELF").is_err()); // truncated
288    }
289
290    #[test]
291    fn macho_is_a_clear_unsupported_error() {
292        for magic in [
293            [0xFE, 0xED, 0xFA, 0xCE],
294            [0xCF, 0xFA, 0xED, 0xFE],
295            [0xCA, 0xFE, 0xBA, 0xBE],
296        ] {
297            let err = AgentPlatform::detect(&magic).unwrap_err();
298            assert!(err.contains("macOS"), "{err}");
299        }
300    }
301
302    #[test]
303    fn unknown_bytes_are_an_error_not_a_windows_guess() {
304        assert!(AgentPlatform::detect(b"#!/bin/sh\necho hi").is_err());
305        assert!(AgentPlatform::detect(b"").is_err());
306    }
307
308    #[test]
309    fn release_keys_follow_the_scheme() {
310        // Windows stays bare — the whole backward-compatibility point.
311        assert_eq!(AgentPlatform::WindowsX86_64.release_key("0.45.4"), "0.45.4");
312        assert_eq!(
313            AgentPlatform::WindowsAarch64.release_key("0.45.4"),
314            "0.45.4"
315        );
316        assert_eq!(
317            AgentPlatform::LinuxX86_64.release_key("0.45.4"),
318            "0.45.4-linux-x86_64"
319        );
320        assert_eq!(
321            AgentPlatform::LinuxAarch64.release_key("0.45.4"),
322            "0.45.4-linux-aarch64"
323        );
324        // Semver prerelease dashes are untouched — only the suffix matters.
325        assert_eq!(
326            AgentPlatform::LinuxX86_64.release_key("0.46.0-rc.1"),
327            "0.46.0-rc.1-linux-x86_64"
328        );
329        assert!(check_release_key(&AgentPlatform::LinuxAarch64.release_key("0.46.0-rc.1")).is_ok());
330    }
331
332    #[test]
333    fn platform_of_key_reads_the_suffix_only() {
334        assert_eq!(platform_of_key("0.45.4"), "windows");
335        assert_eq!(platform_of_key("0.45.4-linux-x86_64"), "linux-x86_64");
336        assert_eq!(platform_of_key("0.45.4-linux-aarch64"), "linux-aarch64");
337        // A version whose PRERELEASE mentions linux still parses by suffix:
338        // `-linux-x86_64` wins, but a bare `1.0.0-linux` is a Windows key
339        // (no arch suffix — odd, but the suffix rule is the contract).
340        assert_eq!(platform_of_key("1.0.0-linux"), "windows");
341        assert_eq!(platform_of_key("1.0.0-rc-linux-x86_64"), "linux-x86_64");
342    }
343
344    #[test]
345    fn base_version_strips_only_the_platform_suffix() {
346        assert_eq!(base_version_of_key("0.45.4"), "0.45.4");
347        assert_eq!(base_version_of_key("0.45.4-linux-x86_64"), "0.45.4");
348        assert_eq!(base_version_of_key("0.45.4-linux-aarch64"), "0.45.4");
349        // Prerelease dashes are not platform suffixes.
350        assert_eq!(base_version_of_key("0.46.0-rc.1"), "0.46.0-rc.1");
351        assert_eq!(
352            base_version_of_key("0.46.0-rc.1-linux-x86_64"),
353            "0.46.0-rc.1"
354        );
355    }
356
357    #[test]
358    fn candidate_keys_cover_bare_then_linux() {
359        assert_eq!(
360            candidate_keys("0.46.0"),
361            vec![
362                "0.46.0".to_string(),
363                "0.46.0-linux-x86_64".to_string(),
364                "0.46.0-linux-aarch64".to_string(),
365            ]
366        );
367    }
368
369    #[test]
370    fn release_key_charset_is_restricted() {
371        for bad in ["", "0.43.99\n evil", "0.43.99\"x", "0.43.99'x", "a b"] {
372            assert!(check_release_key(bad).is_err(), "{bad:?}");
373        }
374        for good in [
375            "0.43.99",
376            "0.43.99-rc.1+build.5",
377            "1.0.0_alpha",
378            "0.45.4-linux-x86_64",
379        ] {
380            check_release_key(good).unwrap();
381        }
382    }
383}