containerization-framework 0.2.0

Rust bindings for Apple's Containerization framework: Linux containers in lightweight VMs, in-process and without a daemon.
//===----------------------------------------------------------------------===//
// The Rust model, read into Containerization's own types.
//
// Read once, on the calling thread, from the opaque Rust values (`RustMount`,
// `RustBootSpec`, ...) into `Sendable` types the async work can carry. Fields
// Rust leaves unset keep the image's or library's value.
//===----------------------------------------------------------------------===//

import Containerization
import ContainerizationExtras
import ContainerizationOCI
import Foundation
// For `FilePermissions` (relayed socket mode).
import SystemPackage

/// A `Vec<String>`'s elements.
func strings(_ vec: RustVec<RustString>) -> [String] {
    vec.map { $0.as_str().toString() }
}

extension Containerization.Mount {
    init(_ mount: RustMountRef) {
        let options = strings(mount.runtime_options())
        let runtimeOptions: RuntimeOptions =
            switch mount.runtime_kind() {
            case .Virtioblk: .virtioblk(options)
            case .Virtiofs: .virtiofs(options)
            case .Generic: .any(options)
            }

        self.init(
            type: mount.mount_type().toString(),
            source: mount.source().toString(),
            destination: mount.destination().toString(),
            options: strings(mount.options()),
            runtimeOptions: runtimeOptions
        )
    }
}

extension UnixSocketConfiguration {
    init(_ socket: RustUnixSocketConfigurationRef) throws {
        // `CModeT` is 16-bit: refuse a mode that doesn't fit, not truncate it.
        let permissions = try socket.permissions().map { mode in
            guard let mode = CModeT(exactly: mode) else {
                throw BridgeError.malformed("socket permissions", String(mode, radix: 8))
            }
            return FilePermissions(rawValue: mode)
        }

        let direction: Direction =
            switch socket.direction() {
            case .Into: .into
            case .OutOf: .outOf
            }

        self.init(
            source: URL(filePath: socket.source().toString()),
            destination: URL(filePath: socket.destination().toString()),
            permissions: permissions,
            direction: direction
        )
    }
}

extension NATInterface {
    init(_ interface: RustNatInterfaceRef) throws {
        self.init(
            ipv4Address: try CIDRv4(interface.ipv4_address().toString()),
            ipv4Gateway: try interface.ipv4_gateway().map { try IPv4Address($0.toString()) },
            ipv6Address: try interface.ipv6_address().map { try CIDRv6($0.toString()) },
            ipv6Gateway: try interface.ipv6_gateway().map { try IPv6Address($0.toString()) },
            macAddress: try interface.mac_address().map { try MACAddress($0.toString()) },
            mtu: interface.mtu()
        )
    }
}

extension DNS {
    init(_ dns: RustDnsRef) throws {
        self.init(
            nameservers: strings(dns.nameservers()),
            domain: dns.domain()?.toString(),
            searchDomains: strings(dns.search_domains()),
            options: strings(dns.options())
        )
        // The library doesn't check; a hostname would silently break DNS.
        try validate()
    }
}

extension Hosts {
    init(_ hosts: RustHostsRef) {
        self.init(
            entries: hosts.entries().map { entry in
                Hosts.Entry(
                    ipAddress: entry.ip_address().toString(),
                    hostnames: strings(entry.hostnames()),
                    comment: entry.comment()?.toString()
                )
            },
            comment: hosts.comment()?.toString()
        )
    }
}

extension User {
    init(_ user: RustUserRef) {
        self.init(
            uid: user.uid(),
            gid: user.gid(),
            umask: user.umask(),
            additionalGids: Array(user.additional_gids()),
            username: user.username().toString()
        )
    }
}

/// `LinuxProcessConfiguration`, over one already seeded from the image.
struct ProcessSettings: Sendable {
    var arguments: [String]?
    var environmentVariables: [String]
    var workingDirectory: String?
    var user: User?

    init(_ process: RustLinuxProcessConfigurationRef) {
        arguments = process.has_arguments() ? strings(process.arguments()) : nil
        environmentVariables = strings(process.environment_variables())
        workingDirectory = process.working_directory()?.toString()
        user = process.has_user() ? User(process.user()) : nil
    }

    func apply(to process: inout LinuxProcessConfiguration) {
        if let arguments {
            process.arguments = arguments
        }

        // Last, so a variable the caller sets beats the image's.
        process.environmentVariables += environmentVariables

        if let workingDirectory {
            process.workingDirectory = workingDirectory
        }

        if let user {
            process.user = user
        }
    }
}

/// `LinuxContainer.Configuration`, over one `ContainerManager` seeded.
struct ContainerSettings: Sendable {
    var process: ProcessSettings
    var cpus: Int
    var memoryInBytes: UInt64
    var hostname: String?
    var sysctl: [String: String]
    var interfaces: [NATInterface]
    var sockets: [UnixSocketConfiguration]
    var mounts: [Containerization.Mount]
    var maskedPaths: [String]
    var readonlyPaths: [String]
    var dns: DNS?
    var hosts: Hosts?
    var virtualization: Bool
    var bootLog: BootLog?
    var ociRuntimePath: String?
    var seccompProfile: LinuxContainer.Configuration.SeccompProfile
    var useInit: Bool

    init(_ configuration: RustLinuxContainerConfigurationRef) throws {
        process = ProcessSettings(configuration.process())
        cpus = Int(configuration.cpus())
        memoryInBytes = configuration.memory_in_bytes()
        hostname = configuration.hostname()?.toString()
        sysctl = Dictionary(
            uniqueKeysWithValues: strings(configuration.sysctl_keys()).compactMap { key in
                configuration.sysctl(key).map { (key, $0.toString()) }
            }
        )
        interfaces = try configuration.interfaces().map { try NATInterface($0) }
        sockets = try configuration.sockets().map { try UnixSocketConfiguration($0) }
        mounts = configuration.mounts().map { Containerization.Mount($0) }
        maskedPaths = strings(configuration.masked_paths())
        readonlyPaths = strings(configuration.readonly_paths())
        dns = configuration.has_dns() ? try DNS(configuration.dns()) : nil
        hosts = configuration.has_hosts() ? Hosts(configuration.hosts()) : nil
        virtualization = configuration.virtualization()

        if configuration.has_boot_log() {
            let bootLog = configuration.boot_log()
            self.bootLog = .file(path: URL(filePath: bootLog.path().toString()), append: bootLog.append())
        } else {
            bootLog = nil
        }

        ociRuntimePath = configuration.oci_runtime_path()?.toString()
        seccompProfile =
            switch configuration.seccomp_mode() {
            case .Unconfined: .unconfined
            case .Default: .default
            case .Profile:
                .profile(try LinuxSeccomp.decode(from: Data((configuration.seccomp_profile()?.toString() ?? "").utf8)))
            }
        useInit = configuration.use_init()
    }

    func apply(to config: inout LinuxContainer.Configuration) {
        process.apply(to: &config.process)
        config.cpus = cpus
        config.memoryInBytes = memoryInBytes
        config.hostname = hostname
        config.sysctl = sysctl
        config.interfaces = interfaces
        config.sockets = sockets
        config.mounts = mounts
        config.maskedPaths = maskedPaths
        config.readonlyPaths = readonlyPaths
        config.dns = dns
        config.hosts = hosts
        config.virtualization = virtualization

        // Absent keeps the manager's, in the container's directory.
        if let bootLog {
            config.bootLog = bootLog
        }

        config.ociRuntimePath = ociRuntimePath
        config.seccompProfile = seccompProfile
        config.useInit = useInit
    }
}