//===----------------------------------------------------------------------===// // Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// import Containerization import ContainerizationError import ContainerizationOCI import ContainerizationOS import Foundation /// A parsed volume specification from user input public struct ParsedVolume { public let name: String public let destination: String public let options: [String] public init(name: String, destination: String, options: [String] = []) { self.name = name self.destination = destination self.options = options } } /// Union type for parsed mount specifications public enum VolumeOrFilesystem { case filesystem(Filesystem) case volume(ParsedVolume) } public struct Parser { public static func memoryString(_ memory: String) throws -> Int64 { let ram = try Measurement.parse(parsing: memory) let mb = ram.converted(to: .mebibytes) return Int64(mb.value) } public static func user( user: String?, uid: UInt32?, gid: UInt32?, defaultUser: ProcessConfiguration.User = .id(uid: 0, gid: 0) ) -> (user: ProcessConfiguration.User, groups: [UInt32]) { var supplementalGroups: [UInt32] = [] let user: ProcessConfiguration.User = { if let user = user, !user.isEmpty { return .raw(userString: user) } if let uid, let gid { return .id(uid: uid, gid: gid) } if uid == nil, gid == nil { // Neither uid nor gid is set. return the default user return defaultUser } // One of uid / gid is left unspecified. Set the user accordingly if let uid { return .raw(userString: "\(uid)") } if let gid { supplementalGroups.append(gid) } return defaultUser }() return (user, supplementalGroups) } public static func platform(os: String, arch: String) -> ContainerizationOCI.Platform { .init(arch: arch, os: os) } public static func resources(cpus: Int64?, memory: String?) throws -> ContainerConfiguration.Resources { var resource = ContainerConfiguration.Resources() if let cpus { resource.cpus = Int(cpus) } if let memory { resource.memoryInBytes = try Parser.memoryString(memory).mib() } return resource } public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] { var output: [String] = [] output.append(contentsOf: Parser.env(envList: imageEnvs)) for envFile in envFiles { let content = try Parser.envFile(path: envFile) output.append(contentsOf: content) } output.append(contentsOf: Parser.env(envList: envs)) return output } public static func envFile(path: String) throws -> [String] { guard FileManager.default.fileExists(atPath: path) else { throw ContainerizationError(.notFound, message: "envfile at \(path) not found") } let data = try String(contentsOfFile: path, encoding: .utf8) let lines = data.components(separatedBy: .newlines) var envVars: [String] = [] for line in lines { let line = line.trimmingCharacters(in: .whitespaces) if line.isEmpty { continue } if !line.hasPrefix("#") { let keyVals = line.split(separator: "=") if keyVals.count != 2 { continue } let key = keyVals[0].trimmingCharacters(in: .whitespaces) let val = keyVals[1].trimmingCharacters(in: .whitespaces) if key.isEmpty || val.isEmpty { continue } envVars.append("\(key)=\(val)") } } return envVars } public static func env(envList: [String]) -> [String] { var envVar: [String] = [] for env in envList { var env = env let parts = env.split(separator: "=", maxSplits: 2) if parts.count == 1 { guard let val = ProcessInfo.processInfo.environment[env] else { continue } env = "\(env)=\(val)" } envVar.append(env) } return envVar } public static func labels(_ rawLabels: [String]) throws -> [String: String] { var result: [String: String] = [:] for label in rawLabels { if label.isEmpty { throw ContainerizationError(.invalidArgument, message: "label cannot be an empty string") } let parts = label.split(separator: "=", maxSplits: 2) switch parts.count { case 1: result[String(parts[0])] = "" case 2: result[String(parts[0])] = String(parts[1]) default: throw ContainerizationError(.invalidArgument, message: "invalid label format \(label)") } } return result } public static func process( arguments: [String], processFlags: Flags.Process, managementFlags: Flags.Management, config: ContainerizationOCI.ImageConfig? ) throws -> ProcessConfiguration { let imageEnvVars = config?.env ?? [] let envvars = try Parser.allEnv(imageEnvs: imageEnvVars, envFiles: processFlags.envFile, envs: processFlags.env) let workingDir: String = { if let cwd = processFlags.cwd { return cwd } if let cwd = config?.workingDir { return cwd } return "/" }() let processArguments: [String]? = { var result: [String] = [] var hasEntrypointOverride: Bool = false // ensure the entrypoint is honored if it has been explicitly set by the user if let entrypoint = managementFlags.entryPoint, !entrypoint.isEmpty { result = [entrypoint] hasEntrypointOverride = true } else if let entrypoint = config?.entrypoint, !entrypoint.isEmpty { result = entrypoint } if !arguments.isEmpty { result.append(contentsOf: arguments) } else { if let cmd = config?.cmd, !hasEntrypointOverride, !cmd.isEmpty { result.append(contentsOf: cmd) } } return result.count > 0 ? result : nil }() guard let commandToRun = processArguments, commandToRun.count > 0 else { throw ContainerizationError(.invalidArgument, message: "Command/Entrypoint not specified for container process") } let defaultUser: ProcessConfiguration.User = { if let u = config?.user { return .raw(userString: u) } return .id(uid: 0, gid: 0) }() let (user, additionalGroups) = Parser.user( user: processFlags.user, uid: processFlags.uid, gid: processFlags.gid, defaultUser: defaultUser) return .init( executable: commandToRun.first!, arguments: [String](commandToRun.dropFirst()), environment: envvars, workingDirectory: workingDir, terminal: processFlags.tty, user: user, supplementalGroups: additionalGroups ) } // MARK: Mounts public static let mountTypes = [ "virtiofs", "bind", "tmpfs", ] public static let defaultDirectives = ["type": "virtiofs"] public static func tmpfsMounts(_ mounts: [String]) throws -> [Filesystem] { var result: [Filesystem] = [] let mounts = mounts.dedupe() for tmpfs in mounts { let fs = Filesystem.tmpfs(destination: tmpfs, options: []) try validateMount(.filesystem(fs)) result.append(fs) } return result } public static func mounts(_ rawMounts: [String]) throws -> [VolumeOrFilesystem] { var mounts: [VolumeOrFilesystem] = [] let rawMounts = rawMounts.dedupe() for mount in rawMounts { let m = try Parser.mount(mount) try validateMount(m) mounts.append(m) } return mounts } public static func mount(_ mount: String) throws -> VolumeOrFilesystem { let parts = mount.split(separator: ",") if parts.count == 0 { throw ContainerizationError(.invalidArgument, message: "invalid mount format: \(mount)") } var directives = defaultDirectives for part in parts { let keyVal = part.split(separator: "=", maxSplits: 2) var key = String(keyVal[0]) var skipValue = false switch key { case "type", "size", "mode": break case "source", "src": key = "source" case "destination", "dst", "target": key = "destination" case "readonly", "ro": key = "ro" skipValue = true default: throw ContainerizationError(.invalidArgument, message: "unknown directive \(key) when parsing mount \(mount)") } var value = "" if !skipValue { if keyVal.count != 2 { throw ContainerizationError(.invalidArgument, message: "invalid directive format missing value \(part) in \(mount)") } value = String(keyVal[1]) } directives[key] = value } var fs = Filesystem() var isVolume = false var volumeName = "" for (key, val) in directives { var val = val let type = directives["type"] ?? "" switch key { case "type": if val == "bind" { val = "virtiofs" } switch val { case "virtiofs": fs.type = Filesystem.FSType.virtiofs case "tmpfs": fs.type = Filesystem.FSType.tmpfs default: throw ContainerizationError(.invalidArgument, message: "unsupported mount type \(val)") } case "ro": fs.options.append("ro") case "size": if type != "tmpfs" { throw ContainerizationError(.invalidArgument, message: "unsupported option size for \(type) mount") } var overflow: Bool var memory = try Parser.memoryString(val) (memory, overflow) = memory.multipliedReportingOverflow(by: 1024 * 1024) if overflow { throw ContainerizationError(.invalidArgument, message: "overflow encountered when parsing memory string: \(val)") } let s = "size=\(memory)" fs.options.append(s) case "mode": if type != "tmpfs" { throw ContainerizationError(.invalidArgument, message: "unsupported option mode for \(type) mount") } let s = "mode=\(val)" fs.options.append(s) case "source": switch type { case "virtiofs", "bind": // Check if it's an absolute directory path first if val.hasPrefix("/") { let url = URL(filePath: val) let absolutePath = url.absoluteURL.path var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { throw ContainerizationError(.invalidArgument, message: "path '\(val)' does not exist") } guard isDirectory.boolValue else { throw ContainerizationError(.invalidArgument, message: "path '\(val)' is not a directory") } fs.source = absolutePath } else { guard VolumeStorage.isValidVolumeName(val) else { throw ContainerizationError(.invalidArgument, message: "Invalid volume name '\(val)': must match \(VolumeStorage.volumeNamePattern)") } // This is a named volume isVolume = true volumeName = val fs.source = val } case "tmpfs": throw ContainerizationError(.invalidArgument, message: "cannot specify source for tmpfs mount") default: throw ContainerizationError(.invalidArgument, message: "unknown mount type \(type)") } case "destination": fs.destination = val default: throw ContainerizationError(.invalidArgument, message: "unknown mount directive \(key)") } } guard isVolume else { return .filesystem(fs) } return .volume( ParsedVolume( name: volumeName, destination: fs.destination, options: fs.options )) } public static func volumes(_ rawVolumes: [String]) throws -> [VolumeOrFilesystem] { var mounts: [VolumeOrFilesystem] = [] for volume in rawVolumes { let m = try Parser.volume(volume) try Parser.validateMount(m) mounts.append(m) } return mounts } public static func volume(_ volume: String) throws -> VolumeOrFilesystem { var vol = volume vol.trimLeft(char: ":") let parts = vol.split(separator: ":") switch parts.count { case 1: throw ContainerizationError(.invalidArgument, message: "anonymous volumes are not supported") case 2, 3: let src = String(parts[0]) let dst = String(parts[1]) // Check if it's an absolute directory path first guard src.hasPrefix("/") else { // Named volume - validate name syntax only guard VolumeStorage.isValidVolumeName(src) else { throw ContainerizationError(.invalidArgument, message: "Invalid volume name '\(src)': must match \(VolumeStorage.volumeNamePattern)") } // This is a named volume let options = parts.count == 3 ? parts[2].split(separator: ",").map { String($0) } : [] return .volume( ParsedVolume( name: src, destination: dst, options: options )) } let url = URL(filePath: src) let absolutePath = url.absoluteURL.path var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { throw ContainerizationError(.invalidArgument, message: "path '\(src)' does not exist") } // This is a filesystem mount var fs = Filesystem.virtiofs( source: URL(fileURLWithPath: absolutePath).absolutePath(), destination: dst, options: [] ) if parts.count == 3 { fs.options = parts[2].split(separator: ",").map { String($0) } } return .filesystem(fs) default: throw ContainerizationError(.invalidArgument, message: "invalid volume format \(volume)") } } public static func validMountType(_ type: String) -> Bool { mountTypes.contains(type) } public static func validateMount(_ mount: VolumeOrFilesystem) throws { switch mount { case .filesystem(let fs): if !fs.isTmpfs { if !fs.source.isAbsolutePath() { throw ContainerizationError( .invalidArgument, message: "\(fs.source) is not an absolute path on the host") } if !FileManager.default.fileExists(atPath: fs.source) { throw ContainerizationError(.invalidArgument, message: "file path '\(fs.source)' does not exist") } } if fs.destination.isEmpty { throw ContainerizationError(.invalidArgument, message: "mount destination cannot be empty") } case .volume(let vol): if vol.destination.isEmpty { throw ContainerizationError(.invalidArgument, message: "volume destination cannot be empty") } // Volume name validation already done during parsing } } /// Parse --publish-port arguments into PublishPort objects /// The format of each argument is `[host-ip:]host-port:container-port[/protocol]` /// (e.g., "127.0.0.1:8080:80/tcp") /// /// - Parameter rawPublishPorts: Array of port arguments /// - Returns: Array of PublishPort objects /// - Throws: ContainerizationError if parsing fails public static func publishPorts(_ rawPublishPorts: [String]) throws -> [PublishPort] { var sockets: [PublishPort] = [] // Process each raw port string for socket in rawPublishPorts { let parsedSocket = try Parser.publishPort(socket) sockets.append(parsedSocket) } return sockets } // Parse a single `--publish-port` argument into a `PublishPort`. public static func publishPort(_ portText: String) throws -> PublishPort { let protoSplit = portText.split(separator: "/") let proto: PublishProtocol let addressAndPortText: String switch protoSplit.count { case 1: addressAndPortText = String(protoSplit[0]) proto = .tcp case 2: addressAndPortText = String(protoSplit[0]) let protoText = String(protoSplit[1]) guard let parsedProto = PublishProtocol(protoText) else { throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)") } proto = parsedProto default: throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)") } let hostAddress: String let hostPortText: String let containerPortText: String let parts = addressAndPortText.split(separator: ":") switch parts.count { case 2: hostAddress = "0.0.0.0" hostPortText = String(parts[0]) containerPortText = String(parts[1]) case 3: hostAddress = String(parts[0]) hostPortText = String(parts[1]) containerPortText = String(parts[2]) default: throw ContainerizationError(.invalidArgument, message: "invalid publish address: \(portText)") } guard let hostPort = Int(hostPortText) else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } guard let containerPort = Int(containerPortText) else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } return PublishPort( hostAddress: hostAddress, hostPort: hostPort, containerPort: containerPort, proto: proto ) } /// Parse --publish-socket arguments into PublishSocket objects /// The format of each argument is `host_path:container_path` /// (e.g., "/tmp/docker.sock:/var/run/docker.sock") /// /// - Parameter rawPublishSockets: Array of socket arguments /// - Returns: Array of PublishSocket objects /// - Throws: ContainerizationError if parsing fails or a path is invalid public static func publishSockets(_ rawPublishSockets: [String]) throws -> [PublishSocket] { var sockets: [PublishSocket] = [] // Process each raw socket string for socket in rawPublishSockets { let parsedSocket = try Parser.publishSocket(socket) sockets.append(parsedSocket) } return sockets } // Parse a single `--publish-socket`` argument into a `PublishSocket`. public static func publishSocket(_ socketText: String) throws -> PublishSocket { // Split by colon to two parts: [host_path, container_path] let parts = socketText.split(separator: ":") switch parts.count { case 2: // Extract host and container paths let hostPath = String(parts[0]) let containerPath = String(parts[1]) // Validate paths are not empty if hostPath.isEmpty { throw ContainerizationError( .invalidArgument, message: "host socket path cannot be empty") } if containerPath.isEmpty { throw ContainerizationError( .invalidArgument, message: "container socket path cannot be empty") } // Ensure container path must start with / if !containerPath.hasPrefix("/") { throw ContainerizationError( .invalidArgument, message: "container socket path must be absolute: \(containerPath)") } // Convert host path to absolute path for consistency let hostURL = URL(fileURLWithPath: hostPath) let absoluteHostPath = hostURL.absoluteURL.path // Check if host socket already exists and might be in use if FileManager.default.fileExists(atPath: absoluteHostPath) { do { let attrs = try FileManager.default.attributesOfItem(atPath: absoluteHostPath) if let fileType = attrs[.type] as? FileAttributeType, fileType == .typeSocket { throw ContainerizationError( .invalidArgument, message: "host socket \(absoluteHostPath) already exists and may be in use") } // If it exists but is not a socket, we can remove it and create socket try FileManager.default.removeItem(atPath: absoluteHostPath) } catch let error as ContainerizationError { throw error } catch { // For other file system errors, continue with creation } } // Create host directory if it doesn't exist let hostDir = hostURL.deletingLastPathComponent() if !FileManager.default.fileExists(atPath: hostDir.path) { try FileManager.default.createDirectory( at: hostDir, withIntermediateDirectories: true) } // Create and return PublishSocket object with validated paths return PublishSocket( containerPath: URL(fileURLWithPath: containerPath), hostPath: URL(fileURLWithPath: absoluteHostPath), permissions: nil ) default: throw ContainerizationError( .invalidArgument, message: "invalid publish-socket format \(socketText). Expected: host_path:container_path") } } }