Skip to main content

a3s_box_core/
platform.rs

1//! Platform types for multi-architecture image builds.
2//!
3//! Represents target OS/architecture pairs used by Buildx-style
4//! multi-platform builds and OCI Image Index manifests.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// A target platform (OS + architecture).
10///
11/// Used for multi-platform builds and OCI Image Index entries.
12/// Compatible with Docker/OCI platform specification.
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Platform {
15    /// Operating system (e.g., "linux").
16    pub os: String,
17    /// CPU architecture (e.g., "amd64", "arm64").
18    pub architecture: String,
19    /// Optional variant (e.g., "v7" for armv7).
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub variant: Option<String>,
22}
23
24impl Platform {
25    /// Create a new platform.
26    pub fn new(os: impl Into<String>, architecture: impl Into<String>) -> Self {
27        Self {
28            os: os.into(),
29            architecture: architecture.into(),
30            variant: None,
31        }
32    }
33
34    /// Create a platform with a variant.
35    pub fn with_variant(
36        os: impl Into<String>,
37        architecture: impl Into<String>,
38        variant: impl Into<String>,
39    ) -> Self {
40        Self {
41            os: os.into(),
42            architecture: architecture.into(),
43            variant: Some(variant.into()),
44        }
45    }
46
47    /// linux/amd64
48    pub fn linux_amd64() -> Self {
49        Self::new("linux", "amd64")
50    }
51
52    /// linux/arm64
53    pub fn linux_arm64() -> Self {
54        Self::new("linux", "arm64")
55    }
56
57    /// Detect the current host platform.
58    pub fn host() -> Self {
59        let arch = match std::env::consts::ARCH {
60            "x86_64" => "amd64",
61            "aarch64" => "arm64",
62            other => other,
63        };
64        let os = match std::env::consts::OS {
65            "macos" => "darwin",
66            other => other,
67        };
68        Self::new(os, arch)
69    }
70
71    /// Parse a platform string like "linux/amd64" or "linux/arm/v7".
72    pub fn parse(s: &str) -> Result<Self, String> {
73        let parts: Vec<&str> = s.split('/').collect();
74        match parts.len() {
75            2 => {
76                let arch = normalize_arch(parts[1]);
77                Ok(Self::new(parts[0], arch))
78            }
79            3 => {
80                let arch = normalize_arch(parts[1]);
81                Ok(Self::with_variant(parts[0], arch, parts[2]))
82            }
83            _ => Err(format!(
84                "Invalid platform '{}': expected 'os/arch' or 'os/arch/variant'",
85                s
86            )),
87        }
88    }
89
90    /// Parse a comma-separated list of platforms.
91    ///
92    /// Example: "linux/amd64,linux/arm64"
93    pub fn parse_list(s: &str) -> Result<Vec<Self>, String> {
94        s.split(',').map(|p| Self::parse(p.trim())).collect()
95    }
96
97    /// Check if this platform matches the host architecture.
98    pub fn is_native(&self) -> bool {
99        *self == Self::host()
100    }
101
102    /// Get the OCI architecture string.
103    pub fn oci_arch(&self) -> &str {
104        &self.architecture
105    }
106}
107
108impl fmt::Display for Platform {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "{}/{}", self.os, self.architecture)?;
111        if let Some(ref v) = self.variant {
112            write!(f, "/{}", v)?;
113        }
114        Ok(())
115    }
116}
117
118/// Runtime capabilities exposed by the current host OS.
119///
120/// This is intentionally separate from [`Platform`], which represents an OCI
121/// image platform. Capabilities describe what the host can execute directly.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct PlatformCapabilities {
124    /// Host OS as reported by Rust.
125    pub os: String,
126    /// Host architecture in OCI naming.
127    pub architecture: String,
128    /// VM backend used on this host.
129    pub vm_backend: VmBackend,
130    /// Host/guest control channel available on this host.
131    pub host_guest_channel: HostGuestChannel,
132    /// Whether Unix domain sockets are available.
133    pub unix_sockets: bool,
134    /// Whether Windows named pipes are available.
135    pub named_pipes: bool,
136    /// Whether the native network proxy is available.
137    pub netproxy: bool,
138    /// Bridge network backend available on this host.
139    pub bridge_network_backend: BridgeNetworkBackend,
140    /// Whether user-defined bridge networks can provide guest outbound NAT.
141    pub bridge_outbound_nat: bool,
142    /// Whether published ports can be bridged on this host.
143    pub published_ports: bool,
144    /// Whether TEE attestation is available in the native runtime.
145    pub tee_attestation: bool,
146    /// Whether sealed storage is available in the native runtime.
147    pub sealed_storage: bool,
148    /// Whether this host can run interactive PTY sessions through the native control channel.
149    pub interactive_pty: bool,
150}
151
152/// VM backend selected for the current host.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "kebab-case")]
155pub enum VmBackend {
156    /// Native libkrun-backed VM execution.
157    Krun,
158    /// Native Windows Hypervisor Platform backend through libkrun.
159    Whpx,
160    /// No supported VM backend for this host.
161    Unsupported,
162}
163
164impl fmt::Display for VmBackend {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            Self::Krun => write!(f, "krun"),
168            Self::Whpx => write!(f, "whpx"),
169            Self::Unsupported => write!(f, "unsupported"),
170        }
171    }
172}
173
174/// Host/guest control transport available on the current host.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "kebab-case")]
177pub enum HostGuestChannel {
178    /// Unix domain sockets.
179    UnixSocket,
180    /// Windows named pipes.
181    NamedPipe,
182    /// No supported channel.
183    Unsupported,
184}
185
186impl fmt::Display for HostGuestChannel {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            Self::UnixSocket => write!(f, "unix-socket"),
190            Self::NamedPipe => write!(f, "named-pipe"),
191            Self::Unsupported => write!(f, "unsupported"),
192        }
193    }
194}
195
196/// User-defined bridge network backend selected for the current host.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "kebab-case")]
199pub enum BridgeNetworkBackend {
200    /// Linux `passt` backend.
201    Passt,
202    /// macOS pure-Rust vfkit netproxy backend.
203    Netproxy,
204    /// No bridge backend is available.
205    Unsupported,
206}
207
208impl fmt::Display for BridgeNetworkBackend {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        match self {
211            Self::Passt => write!(f, "passt"),
212            Self::Netproxy => write!(f, "netproxy"),
213            Self::Unsupported => write!(f, "unsupported"),
214        }
215    }
216}
217
218impl PlatformCapabilities {
219    /// Detect capabilities for the current host.
220    pub fn current() -> Self {
221        let host = Platform::host();
222
223        Self {
224            os: std::env::consts::OS.to_string(),
225            architecture: host.architecture,
226            vm_backend: current_vm_backend(),
227            host_guest_channel: current_host_guest_channel(),
228            unix_sockets: cfg!(unix),
229            named_pipes: cfg!(windows),
230            netproxy: cfg!(target_os = "macos"),
231            bridge_network_backend: current_bridge_network_backend(),
232            // Linux passt provides full outbound NAT. The macOS netproxy
233            // deliberately exposes only DNS and TCP host proxying, so it must
234            // not advertise full NAT (which would also imply UDP and ICMP).
235            bridge_outbound_nat: cfg!(target_os = "linux"),
236            published_ports: cfg!(unix) || cfg!(windows),
237            tee_attestation: cfg!(unix),
238            sealed_storage: cfg!(unix),
239            interactive_pty: cfg!(unix),
240        }
241    }
242
243    /// Whether the host can run the VM runtime directly.
244    pub fn supports_native_vm(&self) -> bool {
245        matches!(self.vm_backend, VmBackend::Krun | VmBackend::Whpx)
246    }
247
248    /// Whether the host has a supported control channel.
249    pub fn supports_host_guest_channel(&self) -> bool {
250        self.host_guest_channel != HostGuestChannel::Unsupported
251    }
252
253    /// Whether user-defined bridge networking is available.
254    pub fn supports_bridge_networking(&self) -> bool {
255        self.bridge_network_backend != BridgeNetworkBackend::Unsupported
256    }
257
258    /// Human-readable bridge networking mode summary for diagnostics.
259    pub fn bridge_networking_summary(&self) -> String {
260        match self.bridge_network_backend {
261            BridgeNetworkBackend::Passt => {
262                "passt (peer networking and outbound NAT supported)".to_string()
263            }
264            BridgeNetworkBackend::Netproxy => {
265                "netproxy (peer networking and outbound TCP proxying supported)".to_string()
266            }
267            BridgeNetworkBackend::Unsupported => "unsupported".to_string(),
268        }
269    }
270}
271
272#[cfg(unix)]
273fn current_vm_backend() -> VmBackend {
274    VmBackend::Krun
275}
276
277#[cfg(windows)]
278fn current_vm_backend() -> VmBackend {
279    VmBackend::Whpx
280}
281
282#[cfg(not(any(unix, windows)))]
283fn current_vm_backend() -> VmBackend {
284    VmBackend::Unsupported
285}
286
287#[cfg(unix)]
288fn current_host_guest_channel() -> HostGuestChannel {
289    HostGuestChannel::UnixSocket
290}
291
292#[cfg(windows)]
293fn current_host_guest_channel() -> HostGuestChannel {
294    HostGuestChannel::NamedPipe
295}
296
297#[cfg(not(any(unix, windows)))]
298fn current_host_guest_channel() -> HostGuestChannel {
299    HostGuestChannel::Unsupported
300}
301
302#[cfg(target_os = "linux")]
303fn current_bridge_network_backend() -> BridgeNetworkBackend {
304    BridgeNetworkBackend::Passt
305}
306
307#[cfg(target_os = "macos")]
308fn current_bridge_network_backend() -> BridgeNetworkBackend {
309    BridgeNetworkBackend::Netproxy
310}
311
312#[cfg(not(any(target_os = "linux", target_os = "macos")))]
313fn current_bridge_network_backend() -> BridgeNetworkBackend {
314    BridgeNetworkBackend::Unsupported
315}
316
317/// Normalize architecture names to OCI conventions.
318fn normalize_arch(arch: &str) -> String {
319    match arch {
320        "x86_64" | "x86-64" => "amd64".to_string(),
321        "aarch64" | "arm64v8" => "arm64".to_string(),
322        "armhf" | "armv7l" => "arm".to_string(),
323        "i386" | "i686" | "x86" => "386".to_string(),
324        other => other.to_string(),
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_platform_new() {
334        let p = Platform::new("linux", "amd64");
335        assert_eq!(p.os, "linux");
336        assert_eq!(p.architecture, "amd64");
337        assert!(p.variant.is_none());
338    }
339
340    #[test]
341    fn test_platform_with_variant() {
342        let p = Platform::with_variant("linux", "arm", "v7");
343        assert_eq!(p.variant, Some("v7".to_string()));
344    }
345
346    #[test]
347    fn test_platform_display() {
348        assert_eq!(Platform::linux_amd64().to_string(), "linux/amd64");
349        assert_eq!(Platform::linux_arm64().to_string(), "linux/arm64");
350        assert_eq!(
351            Platform::with_variant("linux", "arm", "v7").to_string(),
352            "linux/arm/v7"
353        );
354    }
355
356    #[test]
357    fn test_platform_parse() {
358        let p = Platform::parse("linux/amd64").unwrap();
359        assert_eq!(p, Platform::linux_amd64());
360
361        let p = Platform::parse("linux/arm64").unwrap();
362        assert_eq!(p, Platform::linux_arm64());
363
364        let p = Platform::parse("linux/arm/v7").unwrap();
365        assert_eq!(p.architecture, "arm");
366        assert_eq!(p.variant, Some("v7".to_string()));
367    }
368
369    #[test]
370    fn test_platform_parse_normalizes() {
371        let p = Platform::parse("linux/x86_64").unwrap();
372        assert_eq!(p.architecture, "amd64");
373
374        let p = Platform::parse("linux/aarch64").unwrap();
375        assert_eq!(p.architecture, "arm64");
376    }
377
378    #[test]
379    fn test_platform_parse_invalid() {
380        assert!(Platform::parse("linux").is_err());
381        assert!(Platform::parse("a/b/c/d").is_err());
382    }
383
384    #[test]
385    fn test_platform_parse_list() {
386        let platforms = Platform::parse_list("linux/amd64,linux/arm64").unwrap();
387        assert_eq!(platforms.len(), 2);
388        assert_eq!(platforms[0], Platform::linux_amd64());
389        assert_eq!(platforms[1], Platform::linux_arm64());
390    }
391
392    #[test]
393    fn test_platform_parse_list_with_spaces() {
394        let platforms = Platform::parse_list("linux/amd64, linux/arm64").unwrap();
395        assert_eq!(platforms.len(), 2);
396    }
397
398    #[test]
399    fn test_platform_host() {
400        let host = Platform::host();
401        let expected_os = match std::env::consts::OS {
402            "macos" => "darwin",
403            other => other,
404        };
405        assert_eq!(host.os, expected_os);
406        assert!(host.architecture == "amd64" || host.architecture == "arm64");
407    }
408
409    #[test]
410    fn test_platform_is_native() {
411        let host = Platform::host();
412        assert!(host.is_native());
413        // The opposite arch should not be native
414        let other = if host.architecture == "amd64" {
415            Platform::linux_arm64()
416        } else {
417            Platform::linux_amd64()
418        };
419        assert!(!other.is_native());
420    }
421
422    #[test]
423    fn test_platform_serde_roundtrip() {
424        let p = Platform::linux_amd64();
425        let json = serde_json::to_string(&p).unwrap();
426        let parsed: Platform = serde_json::from_str(&json).unwrap();
427        assert_eq!(parsed, p);
428    }
429
430    #[test]
431    fn test_platform_serde_with_variant() {
432        let p = Platform::with_variant("linux", "arm", "v7");
433        let json = serde_json::to_string(&p).unwrap();
434        let parsed: Platform = serde_json::from_str(&json).unwrap();
435        assert_eq!(parsed, p);
436        assert_eq!(parsed.variant, Some("v7".to_string()));
437    }
438
439    #[test]
440    fn test_normalize_arch() {
441        assert_eq!(normalize_arch("x86_64"), "amd64");
442        assert_eq!(normalize_arch("aarch64"), "arm64");
443        assert_eq!(normalize_arch("armhf"), "arm");
444        assert_eq!(normalize_arch("i386"), "386");
445        assert_eq!(normalize_arch("riscv64"), "riscv64");
446    }
447
448    #[test]
449    fn test_platform_equality() {
450        assert_eq!(Platform::linux_amd64(), Platform::new("linux", "amd64"));
451        assert_ne!(Platform::linux_amd64(), Platform::linux_arm64());
452    }
453
454    #[test]
455    fn test_platform_capabilities_match_host() {
456        let capabilities = PlatformCapabilities::current();
457        assert_eq!(capabilities.os, std::env::consts::OS);
458        assert!(capabilities.supports_host_guest_channel());
459
460        #[cfg(unix)]
461        {
462            assert_eq!(capabilities.vm_backend, VmBackend::Krun);
463            assert_eq!(
464                capabilities.host_guest_channel,
465                HostGuestChannel::UnixSocket
466            );
467            assert!(capabilities.unix_sockets);
468            assert!(!capabilities.named_pipes);
469            assert!(capabilities.supports_native_vm());
470            assert!(capabilities.supports_bridge_networking());
471        }
472
473        #[cfg(windows)]
474        {
475            assert_eq!(capabilities.vm_backend, VmBackend::Whpx);
476            assert_eq!(capabilities.host_guest_channel, HostGuestChannel::NamedPipe);
477            assert!(!capabilities.unix_sockets);
478            assert!(capabilities.named_pipes);
479            assert!(capabilities.supports_native_vm());
480            assert!(!capabilities.interactive_pty);
481        }
482    }
483
484    #[test]
485    fn test_platform_capability_display_values() {
486        assert_eq!(VmBackend::Krun.to_string(), "krun");
487        assert_eq!(VmBackend::Whpx.to_string(), "whpx");
488        assert_eq!(HostGuestChannel::UnixSocket.to_string(), "unix-socket");
489        assert_eq!(HostGuestChannel::NamedPipe.to_string(), "named-pipe");
490        assert_eq!(BridgeNetworkBackend::Netproxy.to_string(), "netproxy");
491    }
492
493    #[test]
494    fn test_bridge_networking_summary_documents_outbound_support() {
495        let mut capabilities = PlatformCapabilities::current();
496        capabilities.bridge_network_backend = BridgeNetworkBackend::Netproxy;
497        capabilities.bridge_outbound_nat = false;
498
499        let summary = capabilities.bridge_networking_summary();
500
501        assert!(summary.contains("netproxy"));
502        assert!(summary.contains("outbound TCP proxying supported"));
503        assert!(!capabilities.bridge_outbound_nat);
504    }
505}