kasou 0.1.0

Kasou (仮想) — safe Apple Virtualization.framework bindings for macOS VM management
use objc2::AnyThread;
use objc2::rc::Retained;
use objc2_foundation::NSString;
use objc2_virtualization::{VZMACAddress, VZNATNetworkDeviceAttachment, VZVirtioNetworkDeviceConfiguration};

use crate::KasouError;

/// Network configuration for a VM.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NetworkConfig {
    /// Optional MAC address (e.g. "5a:94:ef:ab:cd:12").
    ///
    /// When set, the VM's network interface will use this MAC address,
    /// enabling deterministic DHCP lease assignments.
    /// This is the core fix that kasou provides — vfkit ignores this parameter.
    pub mac_address: Option<String>,
}

/// Create a virtio network device with NAT attachment and optional MAC address.
///
/// This is the critical function that fixes the vfkit MAC address limitation.
/// `VZMACAddress::initWithString` sets the MAC at the hypervisor level before
/// the VM boots, ensuring the DHCP request uses the correct MAC from the start.
pub(crate) fn create_network_device(
    config: &NetworkConfig,
) -> Result<Retained<VZVirtioNetworkDeviceConfiguration>, KasouError> {
    // SAFETY: VZVirtioNetworkDeviceConfiguration::new() creates a default device.
    let device = unsafe { VZVirtioNetworkDeviceConfiguration::new() };

    // SAFETY: VZNATNetworkDeviceAttachment::new() creates NAT networking.
    // The VM gets a 192.168.64.x address via the macOS DHCP server.
    let attachment = unsafe { VZNATNetworkDeviceAttachment::new() };

    // SAFETY: setAttachment is valid on any VZNetworkDeviceConfiguration subclass.
    unsafe { device.setAttachment(Some(&attachment)) };

    if let Some(mac) = &config.mac_address {
        let vz_mac = parse_mac_address(mac)?;
        // SAFETY: setMACAddress is valid with any non-nil VZMACAddress.
        // This is THE FIX — vfkit never calls this for NAT mode.
        unsafe { device.setMACAddress(&vz_mac) };
    }

    Ok(device)
}

/// Parse a MAC address string into a `VZMACAddress`.
///
/// Accepts colon-separated format: "01:23:45:ab:cd:ef".
fn parse_mac_address(mac: &str) -> Result<Retained<VZMACAddress>, KasouError> {
    let ns_mac = NSString::from_str(mac);
    // SAFETY: initWithString returns nil if the string is not a valid MAC.
    // We handle the nil case by converting to an error.
    let vz_mac = unsafe { VZMACAddress::initWithString(VZMACAddress::alloc(), &ns_mac) };
    vz_mac.ok_or_else(|| KasouError::InvalidMac(mac.to_string()))
}

/// Generate a deterministic locally-administered MAC address.
///
/// Delegates to [`MacAddress::deterministic`] and returns the string form.
///
/// # Example
/// ```
/// let mac = kasou::deterministic_mac("my-host", "cid-k3s");
/// assert!(mac.starts_with("52:55:55:"));
/// ```
pub fn deterministic_mac(seed: &str, unique_id: &str) -> String {
    crate::types::MacAddress::deterministic(seed, unique_id).to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_mac_address_parses() {
        // Test the MAC parsing logic directly (no VZ framework needed)
        let result = parse_mac_address("5a:94:ef:ab:cd:12");
        assert!(result.is_ok());
    }

    #[test]
    fn invalid_mac_address_rejected() {
        let result = parse_mac_address("not-a-mac");
        assert!(result.is_err());
        match result {
            Err(KasouError::InvalidMac(s)) => assert_eq!(s, "not-a-mac"),
            other => panic!("expected InvalidMac, got {other:?}"),
        }
    }

    #[test]
    fn deterministic_mac_is_stable() {
        let mac1 = deterministic_mac("host", "cid-k3s");
        let mac2 = deterministic_mac("host", "cid-k3s");
        assert_eq!(mac1, mac2, "same input should produce same MAC");
        assert!(mac1.starts_with("52:55:55:"), "should use locally-administered prefix");
    }

    #[test]
    fn deterministic_mac_differs_per_cluster() {
        let mac1 = deterministic_mac("host", "cid-k3s");
        let mac2 = deterministic_mac("host", "ryn-k3s");
        assert_ne!(mac1, mac2, "different clusters should get different MACs");
    }

    #[test]
    fn no_mac_address_uses_random() {
        let config = NetworkConfig { mac_address: None };
        let result = create_network_device(&config);
        // Should succeed — random MAC assigned by framework
        assert!(result.is_ok());
    }
}