//===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the container project authors. // // 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 ContainerPersistence import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation import SystemPackage // MARK: - Collection capacity hints // Methods in this file build arrays and dictionaries in loops where the final // size is known from the input parameter count. reserveCapacity() and // Dictionary(minimumCapacity:) avoid O(log n) reallocation copies as the // collection grows incrementally. While this is a micro-optimization for each // individual call, these methods execute on every `container run/create` and // the savings compound at scale. /// A parsed volume specification from user input public struct ParsedVolume { public let name: String public let destination: String public let options: [String] public let isAnonymous: Bool public init(name: String, destination: String, options: [String] = [], isAnonymous: Bool = false) { self.name = name self.destination = destination self.options = options self.isAnonymous = isAnonymous } } /// Union type for parsed mount specifications public enum VolumeOrFilesystem { case filesystem(Filesystem) case volume(ParsedVolume) } public struct Parser { public static func memoryStringAsMiB(_ memory: String) throws -> Int64 { let ram = try Measurement.parse(parsing: memory) let mb = ram.converted(to: .mebibytes) return Int64(mb.value) } public static func memoryStringAsBytes(_ memory: String) throws -> UInt64 { let ram = try Measurement.parse(parsing: memory) let mb = ram.converted(to: .bytes) return UInt64(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 platform(from platform: String) throws -> ContainerizationOCI.Platform { try .init(from: platform) } public static func resources( cpus: Int64?, memory: String?, defaultCPUs: Int, defaultMemory: MemorySize, ) throws -> ContainerConfiguration.Resources { var resource = ContainerConfiguration.Resources() resource.cpus = defaultCPUs resource.memoryInBytes = Int64(defaultMemory.measurement.converted(to: .mebibytes).value).mib() if let cpus { resource.cpus = Int(cpus) } if let memory { resource.memoryInBytes = try Parser.memoryStringAsMiB(memory).mib() } return resource } public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] { var combined: [String] = [] combined.append(contentsOf: Parser.env(envList: imageEnvs)) for envFile in envFiles { let content = try Parser.envFile(path: envFile) combined.append(contentsOf: content) } combined.append(contentsOf: Parser.env(envList: envs)) let deduped = combined.reduce(into: [String: String](minimumCapacity: combined.count)) { map, entry in let key = String(entry.split(separator: "=", maxSplits: 1).first ?? Substring(entry)) map[key] = entry } return deduped.map { $0.value } } public static func envFile(path: String) throws -> [String] { // This is a somewhat faithful Go->Swift port of Moby's envfile // parsing in the cli: // https://github.com/docker/cli/blob/f5a7a3c72eb35fc5ba9c4d65a2a0e2e1bd216bf2/pkg/kvfile/kvfile.go#L81 let data: Data do { // Use FileHandle to support named pipes (FIFOs) and process substitutions // like --env-file <(echo "KEY=value") let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) defer { try? fileHandle.close() } data = try fileHandle.readToEnd() ?? Data() } catch { throw ContainerizationError( .invalidArgument, message: "failed to read envfile at \(path)", cause: error ) } guard let content = String(data: data, encoding: .utf8) else { throw ContainerizationError( .invalidArgument, message: "env file \(path) contains invalid utf8 bytes" ) } let whiteSpaces = " \t" var lines: [String] = [] let fileLines = content.components(separatedBy: .newlines) for line in fileLines { let trimmedLine = line.drop(while: { $0.isWhitespace }) // Skip empty lines and comments if trimmedLine.isEmpty || trimmedLine.hasPrefix("#") { continue } let hasValue: Bool let variable: String let value: String if let equalIndex = trimmedLine.firstIndex(of: "=") { variable = String(trimmedLine[.. [String] { var envVar: [String] = [] for env in envList { var env = env // Only inherit from host if no "=" is present (e.g., "--env VAR") // "VAR=" should set an explicit empty value, not inherit. if !env.contains("=") { 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] = Dictionary(minimumCapacity: rawLabels.count) 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) let rlimits = try Parser.rlimits(processFlags.ulimits) return .init( executable: commandToRun.first!, arguments: [String](commandToRun.dropFirst()), environment: envvars, workingDirectory: workingDir, terminal: processFlags.tty, user: user, supplementalGroups: additionalGroups, rlimits: rlimits ) } // MARK: Mounts public static let mountTypes = [ "virtiofs", "bind", "tmpfs", ] public static let defaultDirectives = ["type": "virtiofs"] public static func tmpfsMounts(_ mounts: [String]) throws -> [Filesystem] { let mounts = mounts.dedupe() var result: [Filesystem] = [] result.reserveCapacity(mounts.count) 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], relativeTo basePath: URL? = nil) throws -> [VolumeOrFilesystem] { let rawMounts = rawMounts.dedupe() var mounts: [VolumeOrFilesystem] = [] mounts.reserveCapacity(rawMounts.count) for mount in rawMounts { let m = try Parser.mount(mount, relativeTo: basePath) try validateMount(m) mounts.append(m) } return mounts } public static func mount(_ mount: String, relativeTo basePath: URL? = nil) 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 case "volume": isVolume = true 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.memoryStringAsMiB(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": // For bind mounts, resolve both absolute and relative paths let url = basePath?.appending(path: val).standardizedFileURL ?? 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 case "volume": // For volume mounts, validate as volume name guard VolumeStorage.isValidVolumeName(val) else { throw ContainerizationError(.invalidArgument, message: "invalid volume name '\(val)': must match \(VolumeStorage.volumeNamePattern)") } // This is a named volume 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) } // If it's a volume type but no source was provided, create an anonymous volume let isAnonymous = volumeName.isEmpty if isAnonymous { volumeName = VolumeStorage.generateAnonymousVolumeName() } return .volume( ParsedVolume( name: volumeName, destination: fs.destination, options: fs.options, isAnonymous: isAnonymous )) } public static func volumes(_ rawVolumes: [String], relativeTo basePath: URL? = nil) throws -> [VolumeOrFilesystem] { var mounts: [VolumeOrFilesystem] = [] mounts.reserveCapacity(rawVolumes.count) for volume in rawVolumes { let m = try Parser.volume(volume, relativeTo: basePath) try Parser.validateMount(m) mounts.append(m) } return mounts } public static func volume(_ volume: String, relativeTo basePath: URL? = nil) throws -> VolumeOrFilesystem { var vol = volume vol.trimLeft(char: ":") let parts = vol.split(separator: ":") switch parts.count { case 1: // Anonymous volume: -v /path // Generate a random name for the anonymous volume let anonymousName = VolumeStorage.generateAnonymousVolumeName() let destination = String(parts[0]) let options: [String] = [] return .volume( ParsedVolume( name: anonymousName, destination: destination, options: options, isAnonymous: true )) case 2, 3: let src = String(parts[0]) let dst = String(parts[1]) // Check if it's a filesystem path (absolute, or relative like ".", "..", "./foo", "../foo") guard src.contains("/") || src == "." || src == ".." 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 = basePath?.appending(path: src).standardizedFileURL ?? 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") /// host-port and container-port can be ranges (e.g., "127.0.0.1:3456-4567:3456-4567/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 publishPorts: [PublishPort] = [] publishPorts.reserveCapacity(rawPublishPorts.count) // Process each raw port string for socket in rawPublishPorts { let publishPort = try Parser.publishPort(socket) publishPorts.append(publishPort) } return publishPorts } // Parse a single `--publish-port` argument into a `PublishPort`. public static func publishPort(_ portText: String) throws -> PublishPort { let publishPortRegex = #/((\[(?[^\]]*)\]|(?[^:].*)):)?(?[^:].*):(?[^:/]*)(/(?.*))?/# guard let match = try publishPortRegex.wholeMatch(in: portText) else { throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)") } let proto: PublishProtocol let protoText = match.proto?.lowercased() ?? "tcp" switch protoText { case "tcp": proto = .tcp case "udp": proto = .udp default: throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)") } let hostAddress: IPAddress if let ipv6 = match.ipv6, !ipv6.isEmpty { guard let address = try? IPAddress(String(ipv6)), case .v6 = address else { throw ContainerizationError(.invalidArgument, message: "invalid publish IPv6 address: \(portText)") } hostAddress = address } else if let ipv4 = match.ipv4, !ipv4.isEmpty { guard let address = try? IPAddress(String(ipv4)), case .v4 = address else { throw ContainerizationError(.invalidArgument, message: "invalid publish IPv4 address: \(portText)") } hostAddress = address } else { hostAddress = try IPAddress("0.0.0.0") } let hostPortText = match.hostPort let containerPortText = match.containerPort let hostPortRangeStart: UInt16 let hostPortRangeEnd: UInt16 let containerPortRangeStart: UInt16 let containerPortRangeEnd: UInt16 let hostPortParts = hostPortText.split(separator: "-") switch hostPortParts.count { case 1: guard let start = UInt16(hostPortParts[0]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } hostPortRangeStart = start hostPortRangeEnd = start case 2: guard let start = UInt16(hostPortParts[0]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } guard let end = UInt16(hostPortParts[1]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } hostPortRangeStart = start hostPortRangeEnd = end default: throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") } let containerPortParts = containerPortText.split(separator: "-") switch containerPortParts.count { case 1: guard let start = UInt16(containerPortParts[0]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } containerPortRangeStart = start containerPortRangeEnd = start case 2: guard let start = UInt16(containerPortParts[0]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } guard let end = UInt16(containerPortParts[1]) else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } containerPortRangeStart = start containerPortRangeEnd = end default: throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") } guard hostPortRangeStart > 1, hostPortRangeStart <= hostPortRangeEnd else { throw ContainerizationError(.invalidArgument, message: "invalid publish host port range: \(hostPortText)") } guard containerPortRangeStart > 1, containerPortRangeStart <= containerPortRangeEnd else { throw ContainerizationError(.invalidArgument, message: "invalid publish container port range: \(containerPortText)") } let hostCount = hostPortRangeEnd - hostPortRangeStart + 1 let containerCount = containerPortRangeEnd - containerPortRangeStart + 1 guard hostCount == containerCount else { throw ContainerizationError(.invalidArgument, message: "publish host and container port counts are not equal: \(hostPortText):\(containerPortText)") } return try PublishPort( hostAddress: hostAddress, hostPort: hostPortRangeStart, containerPort: containerPortRangeStart, proto: proto, count: hostCount ) } /// 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] = [] sockets.reserveCapacity(rawPublishSockets.count) // 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]) 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") } let absoluteHostPath = FilePathOps.absolutePath(FilePath(hostPath)) if FileManager.default.fileExists(atPath: absoluteHostPath.string) { do { let attrs = try FileManager.default.attributesOfItem(atPath: absoluteHostPath.string) 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.string) } catch let error as ContainerizationError { throw error } catch { // For other file system errors, continue with creation } } let hostDir = absoluteHostPath.removingLastComponent() if !FileManager.default.fileExists(atPath: hostDir.string) { try FileManager.default.createDirectory( atPath: hostDir.string, withIntermediateDirectories: true) } return try PublishSocket( containerPath: FilePath(containerPath), hostPath: absoluteHostPath, permissions: nil ) default: throw ContainerizationError( .invalidArgument, message: "invalid publish-socket format \(socketText). Expected: host_path:container_path") } } // MARK: Networks /// Parsed network attachment with optional properties public struct ParsedNetwork { public let name: String public let macAddress: String? public let mtu: UInt32? public init(name: String, macAddress: String? = nil, mtu: UInt32? = nil) { self.name = name self.macAddress = macAddress self.mtu = mtu } } /// Parse network attachment with optional properties /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE] /// Example: "backend,mac=02:42:ac:11:00:02,mtu=1500" public static func network(_ networkSpec: String) throws -> ParsedNetwork { guard !networkSpec.isEmpty else { throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") } let parts = networkSpec.split(separator: ",", omittingEmptySubsequences: false) guard !parts.isEmpty else { throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") } let networkName = String(parts[0]) if networkName.isEmpty { throw ContainerizationError(.invalidArgument, message: "network name cannot be empty") } var macAddress: String? var mtu: UInt32? // Parse properties if any for part in parts.dropFirst() { let keyVal = part.split(separator: "=", maxSplits: 2, omittingEmptySubsequences: false) let key: String let value: String guard keyVal.count == 2 else { throw ContainerizationError( .invalidArgument, message: "invalid property format '\(part)' in network specification '\(networkSpec)'" ) } key = String(keyVal[0]) value = String(keyVal[1]) switch key { case "mac": if value.isEmpty { throw ContainerizationError( .invalidArgument, message: "mac address value cannot be empty" ) } macAddress = value case "mtu": guard let mtuValue = UInt32(value), mtuValue >= 1280, mtuValue <= 65535 else { throw ContainerizationError( .invalidArgument, message: "invalid mtu value '\(value)': must be between 1280 and 65535" ) } mtu = mtuValue default: throw ContainerizationError( .invalidArgument, message: "unknown network property '\(key)'. Available properties: mac, mtu" ) } } return ParsedNetwork(name: networkName, macAddress: macAddress, mtu: mtu) } // MARK: DNS public static func isValidDomainName(_ name: String) -> Bool { guard !name.isEmpty && name.count <= 255 else { return false } return name.components(separatedBy: ".").allSatisfy { Self.isValidDomainNameLabel($0) } } public static func isValidDomainNameLabel(_ label: String) -> Bool { guard !label.isEmpty && label.count <= 63 else { return false } let pattern = #/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/# return !label.ranges(of: pattern).isEmpty } private static let ulimitNameToRlimit: [String: String] = [ "core": "RLIMIT_CORE", "cpu": "RLIMIT_CPU", "data": "RLIMIT_DATA", "fsize": "RLIMIT_FSIZE", "locks": "RLIMIT_LOCKS", "memlock": "RLIMIT_MEMLOCK", "msgqueue": "RLIMIT_MSGQUEUE", "nice": "RLIMIT_NICE", "nofile": "RLIMIT_NOFILE", "nproc": "RLIMIT_NPROC", "rss": "RLIMIT_RSS", "rtprio": "RLIMIT_RTPRIO", "rttime": "RLIMIT_RTTIME", "sigpending": "RLIMIT_SIGPENDING", "stack": "RLIMIT_STACK", ] /// Parse ulimit specifications into Rlimit objects /// Format: =[:] /// Examples: /// - nofile=1024:2048 (soft=1024, hard=2048) /// - nofile=1024 (soft=hard=1024) /// - nofile=unlimited (soft=hard=UINT64_MAX) /// - nofile=1024:unlimited (soft=1024, hard=UINT64_MAX) public static func rlimits(_ rawUlimits: [String]) throws -> [ProcessConfiguration.Rlimit] { var rlimits: [ProcessConfiguration.Rlimit] = [] rlimits.reserveCapacity(rawUlimits.count) var seenTypes: Set = [] for ulimit in rawUlimits { let rlimit = try Parser.rlimit(ulimit) if seenTypes.contains(rlimit.limit) { throw ContainerizationError( .invalidArgument, message: "duplicate ulimit type: \(ulimit.split(separator: "=").first ?? "")" ) } seenTypes.insert(rlimit.limit) rlimits.append(rlimit) } return rlimits } /// Parse a single ulimit specification public static func rlimit(_ ulimit: String) throws -> ProcessConfiguration.Rlimit { let parts = ulimit.split(separator: "=", maxSplits: 1) guard parts.count == 2 else { throw ContainerizationError( .invalidArgument, message: "invalid ulimit format '\(ulimit)': expected =[:]" ) } let typeName = String(parts[0]).lowercased() let valuesPart = String(parts[1]) guard let rlimitType = ulimitNameToRlimit[typeName] else { let validTypes = ulimitNameToRlimit.keys.sorted().joined(separator: ", ") throw ContainerizationError( .invalidArgument, message: "unsupported ulimit type '\(typeName)': valid types are \(validTypes)" ) } let valueParts = valuesPart.split(separator: ":", maxSplits: 1) let soft: UInt64 let hard: UInt64 switch valueParts.count { case 1: // Single value: use for both soft and hard soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName) hard = soft case 2: // Two values: soft:hard soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName) hard = try parseRlimitValue(String(valueParts[1]), typeName: typeName) default: throw ContainerizationError( .invalidArgument, message: "invalid ulimit format '\(ulimit)': expected =[:]" ) } if soft > hard { throw ContainerizationError( .invalidArgument, message: "ulimit '\(typeName)' soft limit (\(soft)) cannot exceed hard limit (\(hard))" ) } return ProcessConfiguration.Rlimit(limit: rlimitType, soft: soft, hard: hard) } private static func parseRlimitValue(_ value: String, typeName: String) throws -> UInt64 { let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased() if trimmed == "unlimited" || trimmed == "-1" { return UInt64.max } guard let parsed = UInt64(trimmed) else { throw ContainerizationError( .invalidArgument, message: "invalid ulimit value '\(value)' for '\(typeName)': must be a non-negative integer or 'unlimited'" ) } return parsed } // MARK: Capabilities /// Parse and validate --cap-add / --cap-drop arguments. /// Returns normalized uppercase CAP_* strings. public static func capabilities(capAdd: [String], capDrop: [String]) throws -> (capAdd: [String], capDrop: [String]) { var normalizedAdd: [String] = [] normalizedAdd.reserveCapacity(capAdd.count) for cap in capAdd { let upper = cap.uppercased() if upper == "ALL" { normalizedAdd.append("ALL") continue } // Validate using CapabilityName from the containerization lib _ = try CapabilityName(rawValue: upper) // Normalize to CAP_ prefixed form let normalized = upper.hasPrefix("CAP_") ? upper : "CAP_\(upper)" normalizedAdd.append(normalized) } var normalizedDrop: [String] = [] normalizedDrop.reserveCapacity(capDrop.count) for cap in capDrop { let upper = cap.uppercased() if upper == "ALL" { normalizedDrop.append("ALL") continue } _ = try CapabilityName(rawValue: upper) let normalized = upper.hasPrefix("CAP_") ? upper : "CAP_\(upper)" normalizedDrop.append(normalized) } return (normalizedAdd, normalizedDrop) } // MARK: Miscellaneous public static func parseBool(string: String) -> Bool? { Parsers.parseBool(string: string) } }