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`), macOS releases at **`<version>-macos-<arch>`**. Semver
12//!     prerelease dashes are fine because consumers always match the
13//!     *suffix* (`-linux-x86_64` / `-macos-aarch64` / …), never a dash
14//!     mid-version.
15//!
16//! Detection is by magic bytes, not filename: `MZ` → PE (arch from the
17//! COFF header's Machine field), `\x7fELF` → ELF (arch from `e_machine`),
18//! thin 64-bit Mach-O → macOS (arch from `cputype`). Universal (fat)
19//! Mach-O and 32-bit Mach-O are clear errors: a release key names exactly
20//! one arch, so the operator publishes one thin binary per arch. Pure byte
21//! inspection, no parsing library — the PE path only needs `e_lfanew` +
22//! the COFF Machine field, which `pelite` (used for VERSIONINFO in
23//! `exe_version.rs`) doesn't surface as a bare number without dragging in
24//! its full header model.
25
26/// Object Store key suffix for Linux x86_64 releases.
27pub const LINUX_SUFFIX_X86_64: &str = "-linux-x86_64";
28/// Object Store key suffix for Linux aarch64 releases.
29pub const LINUX_SUFFIX_AARCH64: &str = "-linux-aarch64";
30/// Both Linux suffixes, for "any Linux key" scans (rollout checks).
31pub const LINUX_SUFFIXES: [&str; 2] = [LINUX_SUFFIX_X86_64, LINUX_SUFFIX_AARCH64];
32
33/// Object Store key suffix for macOS x86_64 (Intel) releases.
34pub const MACOS_SUFFIX_X86_64: &str = "-macos-x86_64";
35/// Object Store key suffix for macOS aarch64 (Apple silicon) releases.
36pub const MACOS_SUFFIX_AARCH64: &str = "-macos-aarch64";
37/// Both macOS suffixes, for "any macOS key" scans.
38pub const MACOS_SUFFIXES: [&str; 2] = [MACOS_SUFFIX_X86_64, MACOS_SUFFIX_AARCH64];
39
40/// Every platform key suffix (all non-Windows platforms), in the order
41/// rollout existence checks probe them after the bare Windows key.
42pub const PLATFORM_SUFFIXES: [&str; 4] = [
43    LINUX_SUFFIX_X86_64,
44    LINUX_SUFFIX_AARCH64,
45    MACOS_SUFFIX_X86_64,
46    MACOS_SUFFIX_AARCH64,
47];
48
49/// The platform an uploaded agent binary runs on.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum AgentPlatform {
52    WindowsX86_64,
53    WindowsAarch64,
54    LinuxX86_64,
55    LinuxAarch64,
56    MacOSX86_64,
57    MacOSAarch64,
58}
59
60impl AgentPlatform {
61    /// Identify the platform from the binary's leading bytes. Errors
62    /// (rather than guesses) on universal / 32-bit Mach-O, on a recognized
63    /// container with an unsupported architecture, and on anything
64    /// unrecognized — a publish that can't name its platform must not
65    /// silently land on the Windows key.
66    pub fn detect(bytes: &[u8]) -> Result<AgentPlatform, String> {
67        if bytes.starts_with(b"MZ") {
68            return detect_pe(bytes);
69        }
70        if bytes.starts_with(b"\x7fELF") {
71            return detect_elf(bytes);
72        }
73        if let Some(result) = detect_macho(bytes) {
74            return result;
75        }
76        Err(
77            "unrecognized binary format (not a Windows PE, Linux ELF, or macOS Mach-O) — is this \
78             a kanade-agent build?"
79                .to_string(),
80        )
81    }
82
83    /// The `agent_releases` Object Store key for `version` on this
84    /// platform. Windows → the bare version (full backward compatibility);
85    /// Linux / macOS → the arch-suffixed key.
86    pub fn release_key(&self, version: &str) -> String {
87        match self.suffix() {
88            Some(suffix) => format!("{version}{suffix}"),
89            None => version.to_string(),
90        }
91    }
92
93    /// The key suffix for this platform, `None` for Windows (bare key).
94    pub fn suffix(&self) -> Option<&'static str> {
95        match self {
96            AgentPlatform::WindowsX86_64 | AgentPlatform::WindowsAarch64 => None,
97            AgentPlatform::LinuxX86_64 => Some(LINUX_SUFFIX_X86_64),
98            AgentPlatform::LinuxAarch64 => Some(LINUX_SUFFIX_AARCH64),
99            AgentPlatform::MacOSX86_64 => Some(MACOS_SUFFIX_X86_64),
100            AgentPlatform::MacOSAarch64 => Some(MACOS_SUFFIX_AARCH64),
101        }
102    }
103
104    /// Human label for audit records / logs (`"windows-x86_64"`, …).
105    pub fn as_str(&self) -> &'static str {
106        match self {
107            AgentPlatform::WindowsX86_64 => "windows-x86_64",
108            AgentPlatform::WindowsAarch64 => "windows-aarch64",
109            AgentPlatform::LinuxX86_64 => "linux-x86_64",
110            AgentPlatform::LinuxAarch64 => "linux-aarch64",
111            AgentPlatform::MacOSX86_64 => "macos-x86_64",
112            AgentPlatform::MacOSAarch64 => "macos-aarch64",
113        }
114    }
115}
116
117/// The platform label for an existing store key, derived from its suffix:
118/// `"linux-x86_64"` / `"linux-aarch64"` / `"macos-x86_64"` /
119/// `"macos-aarch64"` for suffixed keys, `"windows"` for anything else (bare
120/// keys are Windows by definition of the key scheme). Used by the releases
121/// listing, which only has keys to look at.
122pub fn platform_of_key(key: &str) -> &'static str {
123    if key.ends_with(LINUX_SUFFIX_X86_64) {
124        "linux-x86_64"
125    } else if key.ends_with(LINUX_SUFFIX_AARCH64) {
126        "linux-aarch64"
127    } else if key.ends_with(MACOS_SUFFIX_X86_64) {
128        "macos-x86_64"
129    } else if key.ends_with(MACOS_SUFFIX_AARCH64) {
130        "macos-aarch64"
131    } else {
132        "windows"
133    }
134}
135
136/// The rollout-visible version for a store key: the key with any platform
137/// suffix (linux / macos) stripped. A scope's `target_version` always
138/// names the BASE version (`0.46.0`, never `0.46.0-linux-x86_64`) — the
139/// agent's own platform decides which suffixed binary it fetches — so
140/// guards that compare a key against `target_version` must go through
141/// this.
142pub fn base_version_of_key(key: &str) -> &str {
143    for suffix in PLATFORM_SUFFIXES {
144        if let Some(base) = key.strip_suffix(suffix) {
145            return base;
146        }
147    }
148    key
149}
150
151/// Every store key a rollout's existence check should accept for
152/// `version`: the bare (Windows) key plus each linux and macOS platform
153/// key. A version is rollable-out when ANY of these exists — a
154/// Linux-only or macOS-only publish never writes the bare key.
155pub fn candidate_keys(version: &str) -> Vec<String> {
156    let mut keys = Vec::with_capacity(1 + PLATFORM_SUFFIXES.len());
157    keys.push(version.to_string());
158    for suffix in PLATFORM_SUFFIXES {
159        keys.push(format!("{version}{suffix}"));
160    }
161    keys
162}
163
164/// The charset every `agent_releases` key must fit: the key reaches a
165/// quoted `Content-Disposition` filename, generated PowerShell / batch /
166/// shell install scripts, and NATS subjects — restrict the charset
167/// (semver-ish) rather than escaping four different formats. Shared by the
168/// backend publish endpoint, the CLI publish, and the installer endpoint
169/// (which used to carry its own copy).
170pub fn check_release_key(key: &str) -> Result<(), String> {
171    if key.is_empty()
172        || !key
173            .chars()
174            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
175    {
176        return Err(format!(
177            "release key must be non-empty and contain only [A-Za-z0-9._+-], got {key:?}"
178        ));
179    }
180    Ok(())
181}
182
183/// PE: `MZ` DOS stub, `e_lfanew` (u32 LE at 0x3C) → `"PE\0\0"` signature,
184/// COFF Machine field (u16 LE right after). 0x8664 = x86_64, 0xAA64 =
185/// aarch64 (ARM64EC uses the same field for our purposes — a native ARM64
186/// agent build reports 0xAA64).
187fn detect_pe(bytes: &[u8]) -> Result<AgentPlatform, String> {
188    let truncated = || "truncated PE header".to_string();
189    let pe_off = u32::from_le_bytes(
190        bytes
191            .get(0x3C..0x40)
192            .ok_or_else(truncated)?
193            .try_into()
194            .map_err(|_| truncated())?,
195    ) as usize;
196    let coff = bytes.get(pe_off..pe_off + 6).ok_or_else(truncated)?;
197    if coff[..4] != *b"PE\0\0" {
198        return Err("MZ binary without a PE signature".to_string());
199    }
200    match u16::from_le_bytes([coff[4], coff[5]]) {
201        0x8664 => Ok(AgentPlatform::WindowsX86_64),
202        0xAA64 => Ok(AgentPlatform::WindowsAarch64),
203        other => Err(format!(
204            "unsupported PE machine type 0x{other:04X} (kanade-agent ships x86_64 and aarch64 only)"
205        )),
206    }
207}
208
209/// ELF: magic, then `e_machine` (u16 at offset 18) read with the
210/// endianness `EI_DATA` (offset 5) declares. 62 = EM_X86_64, 183 =
211/// EM_AARCH64.
212fn detect_elf(bytes: &[u8]) -> Result<AgentPlatform, String> {
213    let truncated = || "truncated ELF header".to_string();
214    let ei_data = bytes.get(5).ok_or_else(truncated)?;
215    let em = bytes.get(18..20).ok_or_else(truncated)?;
216    let machine = match ei_data {
217        1 => u16::from_le_bytes([em[0], em[1]]),
218        2 => u16::from_be_bytes([em[0], em[1]]),
219        other => return Err(format!("unknown ELF endianness {other}")),
220    };
221    match machine {
222        62 => Ok(AgentPlatform::LinuxX86_64),
223        183 => Ok(AgentPlatform::LinuxAarch64),
224        other => Err(format!(
225            "unsupported ELF machine {other} (kanade-agent ships x86_64 (62) and aarch64 (183) \
226             only)"
227        )),
228    }
229}
230
231/// Mach-O: `None` when the bytes carry no Mach-O magic at all (so the
232/// caller falls through to "unrecognized"), otherwise the verdict. Thin
233/// 64-bit images (`MH_MAGIC_64` in either byte order) are identified by
234/// `cputype` (i32 at offset 4, in the header's byte order):
235/// `CPU_TYPE_X86_64` = 0x01000007, `CPU_TYPE_ARM64` = 0x0100000C.
236/// Universal (fat) wrappers are rejected — a release key names one arch,
237/// so the operator must `lipo -thin` per arch — as is 32-bit Mach-O.
238fn detect_macho(bytes: &[u8]) -> Option<Result<AgentPlatform, String>> {
239    const MH_MAGIC_64_BE: [u8; 4] = [0xFE, 0xED, 0xFA, 0xCF];
240    const MH_MAGIC_64_LE: [u8; 4] = [0xCF, 0xFA, 0xED, 0xFE];
241    const MH_MAGIC_32: [[u8; 4]; 2] = [
242        [0xFE, 0xED, 0xFA, 0xCE], // MH_MAGIC (BE)
243        [0xCE, 0xFA, 0xED, 0xFE], // MH_CIGAM (LE)
244    ];
245    // 0xCAFEBABE is also the Java class-file magic; either way it is not
246    // a publishable agent, so it lands on the same rejection.
247    const FAT_MAGICS: [[u8; 4]; 4] = [
248        [0xCA, 0xFE, 0xBA, 0xBE], // FAT_MAGIC
249        [0xBE, 0xBA, 0xFE, 0xCA], // FAT_CIGAM
250        [0xCA, 0xFE, 0xBA, 0xBF], // FAT_MAGIC_64
251        [0xBF, 0xBA, 0xFE, 0xCA], // FAT_CIGAM_64
252    ];
253
254    let little_endian = if bytes.starts_with(&MH_MAGIC_64_LE) {
255        true
256    } else if bytes.starts_with(&MH_MAGIC_64_BE) {
257        false
258    } else if MH_MAGIC_32.iter().any(|m| bytes.starts_with(m)) {
259        return Some(Err(
260            "32-bit Mach-O is not supported — kanade-agent ships 64-bit macOS builds \
261             (x86_64 / aarch64) only"
262                .to_string(),
263        ));
264    } else if FAT_MAGICS.iter().any(|m| bytes.starts_with(m)) {
265        return Some(Err(
266            "universal (fat) Mach-O binaries are not supported — publish one thin binary per \
267             arch (e.g. `lipo -thin arm64 kanade-agent -output kanade-agent-arm64`)"
268                .to_string(),
269        ));
270    } else {
271        return None;
272    };
273
274    let Some(cpu) = bytes.get(4..8) else {
275        return Some(Err("truncated Mach-O header".to_string()));
276    };
277    let cpu = [cpu[0], cpu[1], cpu[2], cpu[3]];
278    let cputype = if little_endian {
279        u32::from_le_bytes(cpu)
280    } else {
281        u32::from_be_bytes(cpu)
282    };
283    Some(match cputype {
284        0x0100_0007 => Ok(AgentPlatform::MacOSX86_64),
285        0x0100_000C => Ok(AgentPlatform::MacOSAarch64),
286        other => Err(format!(
287            "unsupported Mach-O cputype 0x{other:08X} (kanade-agent ships x86_64 and aarch64 only)"
288        )),
289    })
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    /// Minimal MZ+PE: DOS stub with e_lfanew at 0x80, signature + Machine.
297    fn fake_pe(machine: u16) -> Vec<u8> {
298        let mut b = vec![0u8; 0x80 + 6];
299        b[0] = b'M';
300        b[1] = b'Z';
301        b[0x3C..0x40].copy_from_slice(&0x80u32.to_le_bytes());
302        b[0x80..0x84].copy_from_slice(b"PE\0\0");
303        b[0x84..0x86].copy_from_slice(&machine.to_le_bytes());
304        b
305    }
306
307    /// Minimal ELF ident + e_machine.
308    fn fake_elf(machine: u16, little_endian: bool) -> Vec<u8> {
309        let mut b = vec![0u8; 20];
310        b[..4].copy_from_slice(b"\x7fELF");
311        b[4] = 2; // ELFCLASS64
312        b[5] = if little_endian { 1 } else { 2 };
313        if little_endian {
314            b[18..20].copy_from_slice(&machine.to_le_bytes());
315        } else {
316            b[18..20].copy_from_slice(&machine.to_be_bytes());
317        }
318        b
319    }
320
321    #[test]
322    fn detects_pe_architectures() {
323        assert_eq!(
324            AgentPlatform::detect(&fake_pe(0x8664)).unwrap(),
325            AgentPlatform::WindowsX86_64
326        );
327        assert_eq!(
328            AgentPlatform::detect(&fake_pe(0xAA64)).unwrap(),
329            AgentPlatform::WindowsAarch64
330        );
331        // An MZ with a machine we don't ship is an error, not a guess.
332        assert!(AgentPlatform::detect(&fake_pe(0x14C)).is_err()); // i386
333        // MZ but no PE signature (a DOS binary) is an error too.
334        let mut b = fake_pe(0x8664);
335        b[0x80] = b'X';
336        assert!(AgentPlatform::detect(&b).is_err());
337        // Truncated just past MZ.
338        assert!(AgentPlatform::detect(b"MZ").is_err());
339    }
340
341    #[test]
342    fn detects_elf_architectures_and_endianness() {
343        assert_eq!(
344            AgentPlatform::detect(&fake_elf(62, true)).unwrap(),
345            AgentPlatform::LinuxX86_64
346        );
347        assert_eq!(
348            AgentPlatform::detect(&fake_elf(183, true)).unwrap(),
349            AgentPlatform::LinuxAarch64
350        );
351        // e_machine honors EI_DATA — a big-endian-encoded aarch64 still
352        // reads as aarch64.
353        assert_eq!(
354            AgentPlatform::detect(&fake_elf(183, false)).unwrap(),
355            AgentPlatform::LinuxAarch64
356        );
357        assert!(AgentPlatform::detect(&fake_elf(40, true)).is_err()); // ARM 32
358        assert!(AgentPlatform::detect(b"\x7fELF").is_err()); // truncated
359    }
360
361    /// Minimal thin 64-bit Mach-O header: magic + cputype.
362    fn fake_macho(cputype: u32, little_endian: bool) -> Vec<u8> {
363        let mut b = vec![0u8; 8];
364        if little_endian {
365            b[..4].copy_from_slice(&[0xCF, 0xFA, 0xED, 0xFE]);
366            b[4..8].copy_from_slice(&cputype.to_le_bytes());
367        } else {
368            b[..4].copy_from_slice(&[0xFE, 0xED, 0xFA, 0xCF]);
369            b[4..8].copy_from_slice(&cputype.to_be_bytes());
370        }
371        b
372    }
373
374    #[test]
375    fn detects_macho_architectures_by_cputype() {
376        assert_eq!(
377            AgentPlatform::detect(&fake_macho(0x0100_0007, true)).unwrap(),
378            AgentPlatform::MacOSX86_64
379        );
380        assert_eq!(
381            AgentPlatform::detect(&fake_macho(0x0100_000C, true)).unwrap(),
382            AgentPlatform::MacOSAarch64
383        );
384        // cputype honors the magic's byte order.
385        assert_eq!(
386            AgentPlatform::detect(&fake_macho(0x0100_000C, false)).unwrap(),
387            AgentPlatform::MacOSAarch64
388        );
389        // 32-bit ARM (CPU_TYPE_ARM = 12) in a 64-bit header: error, not a guess.
390        assert!(AgentPlatform::detect(&fake_macho(12, true)).is_err());
391        // Truncated right after the magic.
392        assert!(AgentPlatform::detect(&[0xCF, 0xFA, 0xED, 0xFE]).is_err());
393    }
394
395    #[test]
396    fn fat_and_32bit_macho_are_clear_errors() {
397        for magic in [[0xCA, 0xFE, 0xBA, 0xBE], [0xBF, 0xBA, 0xFE, 0xCA]] {
398            let err = AgentPlatform::detect(&magic).unwrap_err();
399            assert!(err.contains("thin binary per arch"), "{err}");
400        }
401        for magic in [[0xFE, 0xED, 0xFA, 0xCE], [0xCE, 0xFA, 0xED, 0xFE]] {
402            let err = AgentPlatform::detect(&magic).unwrap_err();
403            assert!(err.contains("32-bit Mach-O"), "{err}");
404        }
405    }
406
407    #[test]
408    fn unknown_bytes_are_an_error_not_a_windows_guess() {
409        assert!(AgentPlatform::detect(b"#!/bin/sh\necho hi").is_err());
410        assert!(AgentPlatform::detect(b"").is_err());
411    }
412
413    #[test]
414    fn release_keys_follow_the_scheme() {
415        // Windows stays bare — the whole backward-compatibility point.
416        assert_eq!(AgentPlatform::WindowsX86_64.release_key("0.45.4"), "0.45.4");
417        assert_eq!(
418            AgentPlatform::WindowsAarch64.release_key("0.45.4"),
419            "0.45.4"
420        );
421        assert_eq!(
422            AgentPlatform::LinuxX86_64.release_key("0.45.4"),
423            "0.45.4-linux-x86_64"
424        );
425        assert_eq!(
426            AgentPlatform::LinuxAarch64.release_key("0.45.4"),
427            "0.45.4-linux-aarch64"
428        );
429        assert_eq!(
430            AgentPlatform::MacOSX86_64.release_key("0.45.4"),
431            "0.45.4-macos-x86_64"
432        );
433        assert_eq!(
434            AgentPlatform::MacOSAarch64.release_key("0.45.4"),
435            "0.45.4-macos-aarch64"
436        );
437        // Semver prerelease dashes are untouched — only the suffix matters.
438        assert_eq!(
439            AgentPlatform::LinuxX86_64.release_key("0.46.0-rc.1"),
440            "0.46.0-rc.1-linux-x86_64"
441        );
442        assert!(check_release_key(&AgentPlatform::LinuxAarch64.release_key("0.46.0-rc.1")).is_ok());
443    }
444
445    #[test]
446    fn platform_of_key_reads_the_suffix_only() {
447        assert_eq!(platform_of_key("0.45.4"), "windows");
448        assert_eq!(platform_of_key("0.45.4-linux-x86_64"), "linux-x86_64");
449        assert_eq!(platform_of_key("0.45.4-linux-aarch64"), "linux-aarch64");
450        assert_eq!(platform_of_key("0.45.4-macos-x86_64"), "macos-x86_64");
451        assert_eq!(platform_of_key("0.45.4-macos-aarch64"), "macos-aarch64");
452        // A version whose PRERELEASE mentions linux still parses by suffix:
453        // `-linux-x86_64` wins, but a bare `1.0.0-linux` is a Windows key
454        // (no arch suffix — odd, but the suffix rule is the contract).
455        assert_eq!(platform_of_key("1.0.0-linux"), "windows");
456        assert_eq!(platform_of_key("1.0.0-rc-linux-x86_64"), "linux-x86_64");
457    }
458
459    #[test]
460    fn base_version_strips_only_the_platform_suffix() {
461        assert_eq!(base_version_of_key("0.45.4"), "0.45.4");
462        assert_eq!(base_version_of_key("0.45.4-linux-x86_64"), "0.45.4");
463        assert_eq!(base_version_of_key("0.45.4-linux-aarch64"), "0.45.4");
464        assert_eq!(base_version_of_key("0.45.4-macos-x86_64"), "0.45.4");
465        assert_eq!(base_version_of_key("0.45.4-macos-aarch64"), "0.45.4");
466        // Prerelease dashes are not platform suffixes.
467        assert_eq!(base_version_of_key("0.46.0-rc.1"), "0.46.0-rc.1");
468        assert_eq!(
469            base_version_of_key("0.46.0-rc.1-linux-x86_64"),
470            "0.46.0-rc.1"
471        );
472    }
473
474    #[test]
475    fn candidate_keys_cover_bare_then_linux_then_macos() {
476        assert_eq!(
477            candidate_keys("0.46.0"),
478            vec![
479                "0.46.0".to_string(),
480                "0.46.0-linux-x86_64".to_string(),
481                "0.46.0-linux-aarch64".to_string(),
482                "0.46.0-macos-x86_64".to_string(),
483                "0.46.0-macos-aarch64".to_string(),
484            ]
485        );
486    }
487
488    #[test]
489    fn release_key_charset_is_restricted() {
490        for bad in ["", "0.43.99\n evil", "0.43.99\"x", "0.43.99'x", "a b"] {
491            assert!(check_release_key(bad).is_err(), "{bad:?}");
492        }
493        for good in [
494            "0.43.99",
495            "0.43.99-rc.1+build.5",
496            "1.0.0_alpha",
497            "0.45.4-linux-x86_64",
498        ] {
499            check_release_key(good).unwrap();
500        }
501    }
502}