1pub const LINUX_SUFFIX_X86_64: &str = "-linux-x86_64";
30pub const LINUX_SUFFIX_AARCH64: &str = "-linux-aarch64";
32pub const LINUX_SUFFIXES: [&str; 2] = [LINUX_SUFFIX_X86_64, LINUX_SUFFIX_AARCH64];
34
35pub const MACOS_SUFFIX_AARCH64: &str = "-macos-aarch64";
38
39pub const PLATFORM_SUFFIXES: [&str; 3] = [
42 LINUX_SUFFIX_X86_64,
43 LINUX_SUFFIX_AARCH64,
44 MACOS_SUFFIX_AARCH64,
45];
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum AgentPlatform {
50 WindowsX86_64,
51 WindowsAarch64,
52 LinuxX86_64,
53 LinuxAarch64,
54 MacOSAarch64,
55}
56
57impl AgentPlatform {
58 pub fn detect(bytes: &[u8]) -> Result<AgentPlatform, String> {
64 if bytes.starts_with(b"MZ") {
65 return detect_pe(bytes);
66 }
67 if bytes.starts_with(b"\x7fELF") {
68 return detect_elf(bytes);
69 }
70 if let Some(result) = detect_macho(bytes) {
71 return result;
72 }
73 Err(
74 "unrecognized binary format (not a Windows PE, Linux ELF, or macOS Mach-O) — is this \
75 a kanade-agent build?"
76 .to_string(),
77 )
78 }
79
80 pub fn release_key(&self, version: &str) -> String {
84 match self.suffix() {
85 Some(suffix) => format!("{version}{suffix}"),
86 None => version.to_string(),
87 }
88 }
89
90 pub fn suffix(&self) -> Option<&'static str> {
92 match self {
93 AgentPlatform::WindowsX86_64 | AgentPlatform::WindowsAarch64 => None,
94 AgentPlatform::LinuxX86_64 => Some(LINUX_SUFFIX_X86_64),
95 AgentPlatform::LinuxAarch64 => Some(LINUX_SUFFIX_AARCH64),
96 AgentPlatform::MacOSAarch64 => Some(MACOS_SUFFIX_AARCH64),
97 }
98 }
99
100 pub fn as_str(&self) -> &'static str {
102 match self {
103 AgentPlatform::WindowsX86_64 => "windows-x86_64",
104 AgentPlatform::WindowsAarch64 => "windows-aarch64",
105 AgentPlatform::LinuxX86_64 => "linux-x86_64",
106 AgentPlatform::LinuxAarch64 => "linux-aarch64",
107 AgentPlatform::MacOSAarch64 => "macos-aarch64",
108 }
109 }
110}
111
112pub fn platform_of_key(key: &str) -> &'static str {
118 if key.ends_with(LINUX_SUFFIX_X86_64) {
119 "linux-x86_64"
120 } else if key.ends_with(LINUX_SUFFIX_AARCH64) {
121 "linux-aarch64"
122 } else if key.ends_with(MACOS_SUFFIX_AARCH64) {
123 "macos-aarch64"
124 } else {
125 "windows"
126 }
127}
128
129pub fn base_version_of_key(key: &str) -> &str {
136 for suffix in PLATFORM_SUFFIXES {
137 if let Some(base) = key.strip_suffix(suffix) {
138 return base;
139 }
140 }
141 key
142}
143
144pub fn candidate_keys(version: &str) -> Vec<String> {
149 let mut keys = Vec::with_capacity(1 + PLATFORM_SUFFIXES.len());
150 keys.push(version.to_string());
151 for suffix in PLATFORM_SUFFIXES {
152 keys.push(format!("{version}{suffix}"));
153 }
154 keys
155}
156
157pub fn check_release_key(key: &str) -> Result<(), String> {
164 if key.is_empty()
165 || !key
166 .chars()
167 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
168 {
169 return Err(format!(
170 "release key must be non-empty and contain only [A-Za-z0-9._+-], got {key:?}"
171 ));
172 }
173 Ok(())
174}
175
176fn detect_pe(bytes: &[u8]) -> Result<AgentPlatform, String> {
181 let truncated = || "truncated PE header".to_string();
182 let pe_off = u32::from_le_bytes(
183 bytes
184 .get(0x3C..0x40)
185 .ok_or_else(truncated)?
186 .try_into()
187 .map_err(|_| truncated())?,
188 ) as usize;
189 let coff = bytes.get(pe_off..pe_off + 6).ok_or_else(truncated)?;
190 if coff[..4] != *b"PE\0\0" {
191 return Err("MZ binary without a PE signature".to_string());
192 }
193 match u16::from_le_bytes([coff[4], coff[5]]) {
194 0x8664 => Ok(AgentPlatform::WindowsX86_64),
195 0xAA64 => Ok(AgentPlatform::WindowsAarch64),
196 other => Err(format!(
197 "unsupported PE machine type 0x{other:04X} (kanade-agent ships x86_64 and aarch64 only)"
198 )),
199 }
200}
201
202fn detect_elf(bytes: &[u8]) -> Result<AgentPlatform, String> {
206 let truncated = || "truncated ELF header".to_string();
207 let ei_data = bytes.get(5).ok_or_else(truncated)?;
208 let em = bytes.get(18..20).ok_or_else(truncated)?;
209 let machine = match ei_data {
210 1 => u16::from_le_bytes([em[0], em[1]]),
211 2 => u16::from_be_bytes([em[0], em[1]]),
212 other => return Err(format!("unknown ELF endianness {other}")),
213 };
214 match machine {
215 62 => Ok(AgentPlatform::LinuxX86_64),
216 183 => Ok(AgentPlatform::LinuxAarch64),
217 other => Err(format!(
218 "unsupported ELF machine {other} (kanade-agent ships x86_64 (62) and aarch64 (183) \
219 only)"
220 )),
221 }
222}
223
224fn detect_macho(bytes: &[u8]) -> Option<Result<AgentPlatform, String>> {
233 const MH_MAGIC_64_BE: [u8; 4] = [0xFE, 0xED, 0xFA, 0xCF];
234 const MH_MAGIC_64_LE: [u8; 4] = [0xCF, 0xFA, 0xED, 0xFE];
235 const MH_MAGIC_32: [[u8; 4]; 2] = [
236 [0xFE, 0xED, 0xFA, 0xCE], [0xCE, 0xFA, 0xED, 0xFE], ];
239 const FAT_MAGICS: [[u8; 4]; 4] = [
242 [0xCA, 0xFE, 0xBA, 0xBE], [0xBE, 0xBA, 0xFE, 0xCA], [0xCA, 0xFE, 0xBA, 0xBF], [0xBF, 0xBA, 0xFE, 0xCA], ];
247
248 let little_endian = if bytes.starts_with(&MH_MAGIC_64_LE) {
249 true
250 } else if bytes.starts_with(&MH_MAGIC_64_BE) {
251 false
252 } else if MH_MAGIC_32.iter().any(|m| bytes.starts_with(m)) {
253 return Some(Err(
254 "32-bit Mach-O is not supported — kanade-agent ships 64-bit Apple Silicon \
255 (arm64) macOS builds only"
256 .to_string(),
257 ));
258 } else if FAT_MAGICS.iter().any(|m| bytes.starts_with(m)) {
259 return Some(Err(
260 "universal (fat) Mach-O binaries are not supported — publish one thin binary per \
261 arch (e.g. `lipo -thin arm64 kanade-agent -output kanade-agent-arm64`)"
262 .to_string(),
263 ));
264 } else {
265 return None;
266 };
267
268 let Some(cpu) = bytes.get(4..8) else {
269 return Some(Err("truncated Mach-O header".to_string()));
270 };
271 let cpu = [cpu[0], cpu[1], cpu[2], cpu[3]];
272 let cputype = if little_endian {
273 u32::from_le_bytes(cpu)
274 } else {
275 u32::from_be_bytes(cpu)
276 };
277 Some(match cputype {
278 0x0100_000C => Ok(AgentPlatform::MacOSAarch64),
279 0x0100_0007 => Err(
280 "Intel (x86_64) macOS agents are not supported — kanade supports Apple Silicon \
281 (arm64) Macs only"
282 .to_string(),
283 ),
284 other => Err(format!(
285 "unsupported Mach-O cputype 0x{other:08X} (kanade-agent ships Apple Silicon arm64 \
286 macOS builds only)"
287 )),
288 })
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 fn fake_pe(machine: u16) -> Vec<u8> {
297 let mut b = vec![0u8; 0x80 + 6];
298 b[0] = b'M';
299 b[1] = b'Z';
300 b[0x3C..0x40].copy_from_slice(&0x80u32.to_le_bytes());
301 b[0x80..0x84].copy_from_slice(b"PE\0\0");
302 b[0x84..0x86].copy_from_slice(&machine.to_le_bytes());
303 b
304 }
305
306 fn fake_elf(machine: u16, little_endian: bool) -> Vec<u8> {
308 let mut b = vec![0u8; 20];
309 b[..4].copy_from_slice(b"\x7fELF");
310 b[4] = 2; b[5] = if little_endian { 1 } else { 2 };
312 if little_endian {
313 b[18..20].copy_from_slice(&machine.to_le_bytes());
314 } else {
315 b[18..20].copy_from_slice(&machine.to_be_bytes());
316 }
317 b
318 }
319
320 #[test]
321 fn detects_pe_architectures() {
322 assert_eq!(
323 AgentPlatform::detect(&fake_pe(0x8664)).unwrap(),
324 AgentPlatform::WindowsX86_64
325 );
326 assert_eq!(
327 AgentPlatform::detect(&fake_pe(0xAA64)).unwrap(),
328 AgentPlatform::WindowsAarch64
329 );
330 assert!(AgentPlatform::detect(&fake_pe(0x14C)).is_err()); let mut b = fake_pe(0x8664);
334 b[0x80] = b'X';
335 assert!(AgentPlatform::detect(&b).is_err());
336 assert!(AgentPlatform::detect(b"MZ").is_err());
338 }
339
340 #[test]
341 fn detects_elf_architectures_and_endianness() {
342 assert_eq!(
343 AgentPlatform::detect(&fake_elf(62, true)).unwrap(),
344 AgentPlatform::LinuxX86_64
345 );
346 assert_eq!(
347 AgentPlatform::detect(&fake_elf(183, true)).unwrap(),
348 AgentPlatform::LinuxAarch64
349 );
350 assert_eq!(
353 AgentPlatform::detect(&fake_elf(183, false)).unwrap(),
354 AgentPlatform::LinuxAarch64
355 );
356 assert!(AgentPlatform::detect(&fake_elf(40, true)).is_err()); assert!(AgentPlatform::detect(b"\x7fELF").is_err()); }
359
360 fn fake_macho(cputype: u32, little_endian: bool) -> Vec<u8> {
362 let mut b = vec![0u8; 8];
363 if little_endian {
364 b[..4].copy_from_slice(&[0xCF, 0xFA, 0xED, 0xFE]);
365 b[4..8].copy_from_slice(&cputype.to_le_bytes());
366 } else {
367 b[..4].copy_from_slice(&[0xFE, 0xED, 0xFA, 0xCF]);
368 b[4..8].copy_from_slice(&cputype.to_be_bytes());
369 }
370 b
371 }
372
373 #[test]
374 fn detects_macho_architectures_by_cputype() {
375 for le in [true, false] {
378 let err = AgentPlatform::detect(&fake_macho(0x0100_0007, le)).unwrap_err();
379 assert!(
380 err.contains("Intel (x86_64) macOS agents are not supported"),
381 "{err}"
382 );
383 assert!(err.contains("Apple Silicon"), "{err}");
384 }
385 assert_eq!(
386 AgentPlatform::detect(&fake_macho(0x0100_000C, true)).unwrap(),
387 AgentPlatform::MacOSAarch64
388 );
389 assert_eq!(
391 AgentPlatform::detect(&fake_macho(0x0100_000C, false)).unwrap(),
392 AgentPlatform::MacOSAarch64
393 );
394 assert!(AgentPlatform::detect(&fake_macho(12, true)).is_err());
396 assert!(AgentPlatform::detect(&[0xCF, 0xFA, 0xED, 0xFE]).is_err());
398 }
399
400 #[test]
401 fn fat_and_32bit_macho_are_clear_errors() {
402 for magic in [[0xCA, 0xFE, 0xBA, 0xBE], [0xBF, 0xBA, 0xFE, 0xCA]] {
403 let err = AgentPlatform::detect(&magic).unwrap_err();
404 assert!(err.contains("thin binary per arch"), "{err}");
405 }
406 for magic in [[0xFE, 0xED, 0xFA, 0xCE], [0xCE, 0xFA, 0xED, 0xFE]] {
407 let err = AgentPlatform::detect(&magic).unwrap_err();
408 assert!(err.contains("32-bit Mach-O"), "{err}");
409 }
410 }
411
412 #[test]
413 fn unknown_bytes_are_an_error_not_a_windows_guess() {
414 assert!(AgentPlatform::detect(b"#!/bin/sh\necho hi").is_err());
415 assert!(AgentPlatform::detect(b"").is_err());
416 }
417
418 #[test]
419 fn release_keys_follow_the_scheme() {
420 assert_eq!(AgentPlatform::WindowsX86_64.release_key("0.45.4"), "0.45.4");
422 assert_eq!(
423 AgentPlatform::WindowsAarch64.release_key("0.45.4"),
424 "0.45.4"
425 );
426 assert_eq!(
427 AgentPlatform::LinuxX86_64.release_key("0.45.4"),
428 "0.45.4-linux-x86_64"
429 );
430 assert_eq!(
431 AgentPlatform::LinuxAarch64.release_key("0.45.4"),
432 "0.45.4-linux-aarch64"
433 );
434 assert_eq!(
435 AgentPlatform::MacOSAarch64.release_key("0.45.4"),
436 "0.45.4-macos-aarch64"
437 );
438 assert_eq!(
440 AgentPlatform::LinuxX86_64.release_key("0.46.0-rc.1"),
441 "0.46.0-rc.1-linux-x86_64"
442 );
443 assert!(check_release_key(&AgentPlatform::LinuxAarch64.release_key("0.46.0-rc.1")).is_ok());
444 }
445
446 #[test]
447 fn platform_of_key_reads_the_suffix_only() {
448 assert_eq!(platform_of_key("0.45.4"), "windows");
449 assert_eq!(platform_of_key("0.45.4-linux-x86_64"), "linux-x86_64");
450 assert_eq!(platform_of_key("0.45.4-linux-aarch64"), "linux-aarch64");
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-aarch64"), "0.45.4");
465 assert_eq!(base_version_of_key("0.46.0-rc.1"), "0.46.0-rc.1");
467 assert_eq!(
468 base_version_of_key("0.46.0-rc.1-linux-x86_64"),
469 "0.46.0-rc.1"
470 );
471 }
472
473 #[test]
474 fn candidate_keys_cover_bare_then_linux_then_macos() {
475 assert_eq!(
476 candidate_keys("0.46.0"),
477 vec![
478 "0.46.0".to_string(),
479 "0.46.0-linux-x86_64".to_string(),
480 "0.46.0-linux-aarch64".to_string(),
481 "0.46.0-macos-aarch64".to_string(),
482 ]
483 );
484 }
485
486 #[test]
487 fn release_key_charset_is_restricted() {
488 for bad in ["", "0.43.99\n evil", "0.43.99\"x", "0.43.99'x", "a b"] {
489 assert!(check_release_key(bad).is_err(), "{bad:?}");
490 }
491 for good in [
492 "0.43.99",
493 "0.43.99-rc.1+build.5",
494 "1.0.0_alpha",
495 "0.45.4-linux-x86_64",
496 ] {
497 check_release_key(good).unwrap();
498 }
499 }
500}