1pub const LINUX_SUFFIX_X86_64: &str = "-linux-x86_64";
28pub const LINUX_SUFFIX_AARCH64: &str = "-linux-aarch64";
30pub const LINUX_SUFFIXES: [&str; 2] = [LINUX_SUFFIX_X86_64, LINUX_SUFFIX_AARCH64];
32
33pub const MACOS_SUFFIX_X86_64: &str = "-macos-x86_64";
35pub const MACOS_SUFFIX_AARCH64: &str = "-macos-aarch64";
37pub const MACOS_SUFFIXES: [&str; 2] = [MACOS_SUFFIX_X86_64, MACOS_SUFFIX_AARCH64];
39
40pub 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#[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 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 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 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 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
117pub 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
136pub 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
151pub 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
164pub 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
183fn 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
209fn 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
231fn 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], [0xCE, 0xFA, 0xED, 0xFE], ];
245 const FAT_MAGICS: [[u8; 4]; 4] = [
248 [0xCA, 0xFE, 0xBA, 0xBE], [0xBE, 0xBA, 0xFE, 0xCA], [0xCA, 0xFE, 0xBA, 0xBF], [0xBF, 0xBA, 0xFE, 0xCA], ];
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 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 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; 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 assert!(AgentPlatform::detect(&fake_pe(0x14C)).is_err()); let mut b = fake_pe(0x8664);
335 b[0x80] = b'X';
336 assert!(AgentPlatform::detect(&b).is_err());
337 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 assert_eq!(
354 AgentPlatform::detect(&fake_elf(183, false)).unwrap(),
355 AgentPlatform::LinuxAarch64
356 );
357 assert!(AgentPlatform::detect(&fake_elf(40, true)).is_err()); assert!(AgentPlatform::detect(b"\x7fELF").is_err()); }
360
361 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 assert_eq!(
386 AgentPlatform::detect(&fake_macho(0x0100_000C, false)).unwrap(),
387 AgentPlatform::MacOSAarch64
388 );
389 assert!(AgentPlatform::detect(&fake_macho(12, true)).is_err());
391 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 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 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 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 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}